MailMint API reference

An inbound email address in, one frozen JSON object out — with a computed confidence, a source and a verbatim evidence span on every field you asked for. Delivered by webhook, by polling, or into n8n.

Base URLyour deployment — see below
AuthAuthorization: Bearer mm_live_…
Everything under/v1

Overview #

There is no public hosted deployment yet, so there is no base URL this page can honestly print as if it were live. Set MAILMINT_URL to whichever MailMint you are calling — http://127.0.0.1:3100 when you run it from the repository — and every example below works verbatim.

Everything is JSON in, JSON out, except three endpoints that return bytes: GET /v1/messages/{id}/raw returns message/rfc822, GET /v1/attachments/{id} returns the file, and GET /healthz needs no auth.

Every response carries an X-Request-Id header, and every error body repeats it as error.request_id. Quote it if you get in touch; it is the key that ties a response to the service’s logs.

The three shapes you will meet #

ShapeReturned byWhat it is
The message object GET /v1/messages/{id}, the webhook body, GET /v1/events[].message, POST /v1/parse, POST /v1/test/deliver, both re-parse endpoints The frozen result shape. Documented field by field. One function builds it, so all three transports are byte-for-byte identical.
The summaryGET /v1/messages A cheap row for paging: id, from, subject, status, flags, needs_review, the extracted fields, and attachment metadata without any bytes or text.
The mailboxevery /v1/mailboxes route Address, schema, schema version, webhook settings.

Authentication #

Every /v1 call takes an API key as a bearer token. X-API-Key is accepted as an alternative for clients that cannot set Authorization.

curl "$MAILMINT_URL/v1/usage" -H "Authorization: Bearer $MAILMINT_API_KEY"
PrefixBehaviour
mm_live_A normal key. Parses count against your monthly quota.
mm_test_Identical in every other way, but nothing it does is ever billed or counted. This is the key for CI.

Keys are shown once, at creation, and cannot be read back — they are stored as a salted SHA-256 digest, never in the clear. Create and revoke them on the dashboard. A revoked key stops working on the very next request. The API refuses to revoke your only key (409 last_key), because that would lock you out of the account entirely.

A key that is missing gives 401 missing_api_key; one with the wrong shape or a revoked one gives 401 invalid_api_key. Both carry a hint saying what to do next.

Quota & billing #

Quota is counted in parsed emails per calendar month, and it rolls over lazily on the first request after the month turns. GET /v1/usage reports where you are.

Two things behave differently, deliberately:

  • An API call that would exceed the quota is refused with 402 quota_exceeded. That is the right answer for a call you made on purpose.
  • Inbound mail is never refused. A message that arrives when you are out of quota is still accepted, still stored and still delivered — only the parsing is skipped, and the message says so in its flags. Bouncing a stranger’s mail because a customer hit a limit is a failure we would have to explain to the sender, so we do not do it.

There is no pricing decision yet. Plan names and quotas exist in the service so that quota accounting has something to count against, and they are not an offer. When there is a price it will appear on the home page first.

Mailboxes & addresses #

A mailbox is one inbound address, plus the schema and the webhook that belong to it. Its address is <token>@<inbound domain>, where the token is twelve characters of lowercase Crockford base32 with i, l, o and u left out so it cannot be misread aloud.

FormExampleRoutes to
Canonicalk7m2xq4h9bwz@parse.example.comthe mailbox
Slug aliasinvoices.k7m2xq4h9bwz@parse.example.comthe same mailbox
Sub-addressk7m2xq4h9bwz+acme@parse.example.comthe same mailbox

The slug is a display convenience only: it is lowercased and stripped of anything that could introduce a dot, so it can never change which label is the token. The +tag form is the cheap way to route several senders into one mailbox and still tell them apart — the tag survives on envelope.to.

The hosted address is not live. Addresses are minted and reserved when you create a mailbox, but external mail cannot reach them until a domain is pointed at the service, and none has been bought yet. Everything else — schemas, webhooks, re-parsing, the event feed — works today through POST /v1/test/deliver and POST /v1/parse. See the quickstart.

Schemas #

A schema is the list of fields you want extracted. It is validated at write time, loudly — a field you thought you defined but which the parser silently ignored is the worst failure available here, because the mail arrives, the field is null, and nothing anywhere says why.

the array form — what is stored
"schema": [
  { "name": "total", "type": "number", "description": "grand total incl. tax",
    "required": true, "hint": "labelled Total or Amount Due" }
]
the object shorthand — accepted, normalised to the above
"schema": { "invoice_number": "string", "total": { "type": "number", "hint": "labelled Total" } }
KeyTypeMeaning
namestring, requiredBecomes the key in fields. Must match ^[A-Za-z_][A-Za-z0-9_]{0,63}$ — it has to be usable as a JSON key. Duplicates are rejected.
typestringOne of the thirteen field types. Defaults to string.
descriptionstring, ≤ 500 charsThe single biggest lever on accuracy. Write it the way you would explain the field to a new colleague.
hintstring, ≤ 300 charsThe label the mail actually uses, e.g. "labelled Amount Due".
requiredbooleanA required field that is missing is still null — it is flagged, never fabricated.
optionsarrayenum only. Up to 200 values.
itemsobjectarray only: {"type": …}, plus fields when the item type is object.
fieldsarrayobject only: the nested field list.

A schema may define at most 60 fields and nest at most two levels of objects. Both limits are refused with 400 invalid_schema and a message that names the offending path. The width limit is not arbitrary: a schema that wide costs accuracy as well as tokens, and splitting it across mailboxes is the better answer.

Field types #

Coercion happens after the extraction, never before. A value that will not coerce becomes null with confidence 0 and the flag type_error:<field>. It is never silently rounded, guessed, or replaced with a plausible-looking substitute.

TypeYou get backNotes
string"INV-2291"The text as written in the mail.
number31.5Decimals allowed.
integer3A whole number.
booleantrue
date"2026-09-08"Normalised to YYYY-MM-DD.
datetime"2026-09-08T14:00:00Z"ISO-8601, UTC.
email"billing@acme.example"Validated.
url"https://…"
phone"+1 555 0100"
currency{ "amount": 31.5, "currency": "USD" } Always this pair, never a bare number.
enum"paid" Requires options. A value outside the list becomes null and raises enum_violation:<field>.
array[ … ] Requires items: { "type": … }. Item type object also needs items.fields. See line items.
object{ … }Requires fields: [ … ].

Schema versions #

Every change to a mailbox schema increments schema_version, and every parse records which version produced it in parse.schema_version. That is what makes “why did this message come out differently from that one” answerable rather than a guess, and it is what re-parsing reaches back through. Docparser sells the same capability as “Parser Version Control”, a paid add-on; here it is on by default and cannot be turned off.

The message object #

One shape, three transports. This is what GET /v1/messages/{id} returns, what is POSTed to your webhook, and what appears as message in the event feed.

the complete shape
{
  "id": "msg_01JQ8Z3K4M5N6P7Q8R9S",
  "mailbox": { "id": "mbx_01JQ8Y…", "address": "k7m2xq4h9bwz@parse.example.com", "name": "Invoices" },
  "received_at": "2026-08-25T09:14:03.221Z",
  "status": "parsed",
  "envelope": {
    "from": "billing@acme.example",
    "to": ["k7m2xq4h9bwz@parse.example.com"],
    "helo": "mail.acme.example",
    "remote_ip": "203.0.113.7",
    "tls": true
  },
  "headers": {
    "message_id": "<a1b2c3@mail.acme.example>",
    "date": "2026-08-25T09:14:01.000Z",
    "subject": "Invoice INV-2291 from Acme Ltd",
    "from": { "name": "Acme Billing", "email": "billing@acme.example" },
    "to":   [ { "name": null, "email": "k7m2xq4h9bwz@parse.example.com" } ],
    "cc": [], "reply_to": [],
    "in_reply_to": null, "references": [],
    "raw": { "x-mailer": "…", "list-unsubscribe": "…" }
  },
  "body": {
    "text": "plain text, quoted-printable decoded, charset-normalised to UTF-8",
    "html": "<html>…</html>",
    "text_from_html": "text rendered from the html part when there is no text/plain",
    "stripped_text": "the body with the quoted reply chain and signature removed",
    "language": "en"
  },
  "attachments": [ … ],
  "auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "spam_score": 0.4 },
  "tables": [ … ],
  "detected": { … },
  "fields": { … },
  "flags": [],
  "needs_review": false,
  "parse": {
    "request_id": "req_…",
    "schema_version": 3,
    "model": "deepseek-ai/DeepSeek-V4-Flash-0731-TEE",
    "llm_used": true,
    "timings_ms": { "total": 4735, "mime": 22, "deterministic": 14, "llm": 4611, "persist": 12 },
    "cost": { "input_tokens": 4120, "output_tokens": 380, "llm_calls": 1, "usd": 0.00021 },
    "warnings": []
  },
  "raw_url": "https://…/v1/messages/msg_01JQ8Z…/raw"
}
KeyWhat it is
idSortable, prefixed msg_. null from POST /v1/parse, which stores nothing.
received_atWhen we accepted the message — not the sender’s Date: header, which is in headers.date and may be wrong, or null.
statusparsed, or a state you can filter GET /v1/messages by.
envelopeWhat the SMTP conversation said: MAIL FROM, RCPT TO, the HELO name, the connecting IP, and whether the hop was over TLS. This is frequently not what headers.from says, and the difference matters.
headersDecoded and unfolded, RFC 2047 encoded words resolved. Everything we do not name explicitly is under headers.raw with lowercased keys.
body.stripped_textThe body with the trailing quoted reply chain and the signature removed. This is usually what you want to extract from.
authSPF, DKIM and DMARC. Documented in full below, including why dkim: "body_altered" is a different thing from dkim: "fail".
tablesDeterministic table extraction. See below.
detectedAlways present, always deterministic, and does not need a schema: document type (invoice, receipt, order, shipping, form, calendar, generic), plus every emails, urls, phones, amounts, dates, ids and addresses found.
fieldsYour schema’s answers. {} when no schema is set, and the message is flagged no_schema.
parse.modelThe model that answered, or null when the deterministic layer resolved everything and no model ran at all.
parse.costWhat this parse actually cost: input and output tokens, how many model calls were made, and the amount in USD. A message the rules layer resolved on its own reports llm_calls: 0. Parseur puts credits_used on its document object; nobody else in this category publishes per-document cost, and cost is the loudest complaint in the forum data, so it is here by default.
raw_urlThe original RFC822 bytes, API-key authenticated. null once the raw message has aged out or was never stored.

Confidence & evidence #

Every entry in fields has exactly four keys.

"total": {
  "value":      31.5,                  // null when not found. Never invented, never "N/A".
  "confidence": 0.97,                  // float 0..1, computed by us
  "source":     "rule",                // rule | llm | rule+llm | header | attachment | none
  "evidence":   "Total: $31.50"        // a verbatim substring of the input, or null
}

Confidence is computed, never taken from the model. This is the whole point, and it is worth being precise about why. A widely repeated finding from practitioners is that a raw LLM self-report is uncalibrated — “the same email comes back as 0.8 one day and 0.9 the next”, and “a model will report 0.95 on a PO code it hallucinated”. A number like that is worse than no number, because it reaches you wearing a confidence score.

So the score is derived from signals that can be checked without asking the model’s opinion, in roughly this order of weight:

  1. Evidence verification. evidence must be a verbatim substring of the input, normalised for whitespace and case. This test actually runs on every field. Failing it is near-disqualifying rather than a soft penalty: the confidence is multiplied down and capped, and the field is flagged hallucinated_evidence:<field>.
  2. Independent agreement. A deterministic rules layer and the model both run. When they produce the same answer independently, source becomes "rule+llm" and the score reaches its ceiling. When they disagree, the rule’s value is kept, the confidence drops sharply, and rule_llm_disagreement:<field> is raised. No competitor runs both layers, so no competitor can detect this at all.
  3. Arithmetic consistency. On invoices and receipts: do the line items sum to the subtotal, and does subtotal + tax + shipping − discount equal the total? Reconciling raises confidence across the whole cluster of related fields; failing sets arithmetic_mismatch. This is the check that catches “forty rows in the mail, one row in the output” — the failure that is otherwise silent, because every individual value looks plausible.
  4. Type, format and enum validity after coercion.
  5. The model’s own self-report — the smallest weight, and it may only ever lower a score, never raise one above what the verifiable signals justify.
sourceMeans
ruleA deterministic rule found it. No model was involved for this field.
llmOnly the model found it.
rule+llmBoth layers ran and agreed independently. The highest-trust source.
headerLifted from a message header.
attachmentRead out of an attachment.
noneNot found. value is null and confidence is 0.

Is the number honest? #

A confidence score is only worth reading if it declines when the answer gets worse. So every extracted value is bucketed by the confidence we reported and checked against a hand label. Measured on a hold-out set the parser had never seen — 36 deliberately adversarial messages, 163 labelled field slots, 25 August 2026:

Reported confidenceValuesActually correct
0.9 – 1.06291.9 %
0.7 – 0.92982.8 %
0.6 – 0.7757.1 %
below 0.6520.0 %

Overall on that set: 83.5 % precision, 91.5 % recall. On our own labelled corpus the same pipeline scores 100 % on both, and we do not quote that number, because a score measured on the cases you tuned against is not a score.

The commitment is the shape of that table, not any single figure in it: if values reported at 0.9+ stop being right about 90 % of the time, the formula is wrong and gets fixed. Both runs, and every case each one gets wrong, are in packages/parser/.

Determinism, and where to stand when it is wrong #

The honest thing the incumbents have that an LLM pipeline does not: a rule that returns the same answer every time, and somewhere to stand when it is wrong. This is not us being generous — it is Parseur’s own published comparison of their AI engine against their template engine, which lists “Deterministic results with debugging support” as a pro of the templates and “Results may vary slightly; limited debugging capability” as a con of the AI.

When a Mailparser rule returns the wrong value, the user opens the filter chain and watches the preview change filter by filter until they see which one ate it. When a model returns the wrong value, the usual recourse is to edit a hint string and hope. So MailMint keeps three properties that make source and evidence a real debugging surface rather than decoration:

  1. A matched rule wins, and its value is what you get. For every field the deterministic layer resolved, the output is byte-identical run to run. That is the determinism the incumbents sell, and it covers the large majority of field slots on ordinary invoice mail.
  2. The model still runs, and still sees those fields — not to overrule the rule, but to cross-examine it. Agreement is what earns a confidence above ~0.9; disagreement keeps the rule’s value and lowers the score with rule_llm_disagreement:<field>. Determinism of the value, verification of the confidence. Skipping the model wherever the rule looks confident would remove the strongest verification signal exactly where it matters most.
  3. evidence is the “where did this come from” pointer. It is a verbatim span for every source, and it contains the value. It is what the incumbents give you by letting you watch a preview, except you can assert on it in code.

Two limits, stated rather than papered over. A long document with many fields costs us more than it costs a rule chain — which is why parse.cost is published on every result. And our non-English accuracy rests on the model wherever no deterministic rules exist; the label synonym tables are Latin-script only.

Flags & needs_review #

flags is a flat array of strings. Field-scoped flags carry the field name after a colon (low_confidence:due_date); whole-message flags do not.

A message with any flag still delivers. Nothing is ever silently dropped, and no flag suppresses a webhook. The flags are how you find out something went wrong — they are not an error channel.

FlagRaised whenSets needs_review
low_confidence:<field>The computed confidence fell below 0.6.yes
missing_required:<field>A field marked required came back null.yes
type_error:<field>The value would not coerce to the declared type.yes
hallucinated_evidence:<field>The quoted evidence is not actually in the message.yes
enum_violation:<field>The value was outside the declared options.yes
rule_llm_disagreement:<field>The rules layer and the model answered differently. The rule’s value is kept.yes
arithmetic_mismatchThe line items do not add up to the stated total.yes
table_truncatedA table hit the row cap and is known to be short.yes
attachment_unreadableAn attachment could not be read.yes
array_incomplete:<field>The model returned fewer rows than the deterministic extractors found.no
array_source_disagreement:<field>Two independent row extractors produced different row sets. Flagged rather than quietly resolved.no
no_schemaThe mailbox has no schema, so fields is {}.no
llm_unavailableThe model chain could not be reached. Deterministic results are still returned.no
truncated_bodyThe body exceeded the text cap and was cut.no
attachment_too_largeAn attachment exceeded the storage cap. Its metadata is still recorded.no
spam_suspectedAn upstream spam score of 5 or more.no
auth_fail:spf · :dkim · :dmarc The named mechanism reported fail, softfail, permerror or temperror.no

needs_review #

A single top-level boolean: true when any flag in the right-hand column above says a human should look. It is derived in one place and used everywhere, so the API, the dashboard’s review queue and the webhook body can never disagree about it.

It is also a first-class query, with its own index:

# everything that needs a human, newest first
curl "$MAILMINT_URL/v1/messages?needs_review=true&view=review" \
  -H "Authorization: Bearer $MAILMINT_API_KEY"

# everything that hit one specific flag
curl "$MAILMINT_URL/v1/messages?flag=arithmetic_mismatch" \
  -H "Authorization: Bearer $MAILMINT_API_KEY"

view=review adds an issues array to each row — which flag fired, on which field, what the value and confidence were, and the evidence it came from. That is enough to accept or fix a message without opening it.

Tables & line items #

tables is deterministic table extraction from the text and HTML parts, present whether or not you defined a schema.

"tables": [
  {
    "source":  "html",              // which part it came from
    "index":   0,
    "headers": ["Item", "Qty", "Amount"],
    "rows":    [ ["Widget", "3", "$27.00"] ],
    "records": [ { "Item": "Widget", "Qty": "3", "Amount": "$27.00" } ],
    "row_count": 39,
    "truncated": false
  }
]

row_count and truncated are the point. “The mail had forty rows and I got one” is the most common unanswered complaint in this category, and the reason it stays unanswered is that a short array looks exactly like a correct one. Here it does not: the count travels with the table, and a table cut off at the cap (5,000 rows) also raises table_truncated, which sets needs_review.

Row extraction runs from more than one independent source and reconciles the candidates, because real HTML email frequently contains no real table at all — Outlook compatibility pushes every large sender towards nested single-cell tables, where a <table> → headers/rows extractor structurally cannot see the line items. When two sources disagree the field is flagged array_source_disagreement:<field> rather than silently resolved in favour of one.

To get line items as typed data rather than strings, declare an array of object:

{ "name": "line_items", "type": "array",
  "items": { "type": "object", "fields": [
    { "name": "description", "type": "string" },
    { "name": "qty",         "type": "integer" },
    { "name": "amount",      "type": "number" }
  ] } }

Attachments #

"attachments": [
  {
    "id": "att_01JQ8Z3K4M5N6P7Q8R9S",
    "filename": "invoice-2291.pdf",
    "content_type": "application/pdf",
    "size": 48213,
    "sha256": "9f2c8b…",
    "inline": false,
    "content_id": null,
    "url": "https://…/v1/attachments/att_01JQ8Z…",
    "content_base64": "…"        // only with ?include=attachments
  }
]

Every attachment is its own addressable object — nothing is zipped together, and nothing is flattened into the same namespace as our own metadata. inline and content_id are what separate a customer’s invoice from the sender’s Outlook signature logo, which is a real, documented failure elsewhere in this category.

Query parameterEffect
?include=attachmentsInlines the bytes as content_base64.
?include=extracted_textReturns the full extracted text instead of the 2,000-character preview.
?exclude=extractedDrops the extracted block entirely — worth doing on a hot polling loop.

Attachment content extraction is built but not yet wired in. The extractor exists — packages/docs handles PDF text and tables, XLSX, CSV, DOCX, plain text and HTML, with an OCR fallback for scans — and the response shape reserves attachments[].extracted for {kind, text, pages, tables, meta}, and the n8n node already knows how to fan line items out of it. Nothing connects the two yet, so that slot is not populated today: a message whose data lives only inside the attachment gives you the file, its metadata and its SHA-256, not its contents. Written against the code as of 25 August 2026.

SPF, DKIM and DMARC #

"auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "spam_score": 0.4 }
KeyValues
spfpass · fail · softfail · neutral · none · temperror · permerror
dkimpass · fail · body_altered · none · temperror · permerror
dmarcpass · fail · none · temperror · permerror
spam_scoreA number, or null. 5 or above raises spam_suspected.

dkim: "body_altered" is not dkim: "fail" #

A DKIM signature can fail for two completely different reasons, and collapsing them into one code is always cheaper to write and always wrong to consume:

  • The signature or the key is wrong. Somebody forged the message, or the selector is gone. That is fail.
  • The body hash does not match. The message was signed correctly and then modified after signing. That is body_altered.

The second is overwhelmingly benign and overwhelmingly common: forwarding, mailing lists, and corporate security gateways that rewrite links all break the body hash while leaving a perfectly legitimate message behind. A large share of mail reaching a parser is forwarded from Gmail, so reporting that as a signature failure would mark the happy path as suspicious.

Write if (auth.dkim === "fail") and you get forgeries, not your colleague’s forward. That is the whole reason the value exists, and it is branchable without reading any sub-object. A finer breakdown — per-signature results, and a failure_type of body_hash, signature, key, policy or dns — is computed alongside it for anyone who wants it.

Where these values come from #

  • MailMint’s own SMTP server verifies them itself — a real SPF evaluation, real DKIM signature and body-hash verification against the signing domain’s live DNS key, and a real DMARC alignment check. It was measured against 69 real third-party signatures from senders including gmail.com, Fastmail, Pobox and GMX, all 69 of which verify; 33 of those use relaxed/simple canonicalisation, which is the combination naive implementations get wrong.
  • On the hosted Cloudflare Email Routing path, spf is reported as none, because Email Routing gives the receiving worker no client IP and SPF cannot be evaluated without one. We report none rather than guessing at a result we did not compute.
  • When mail reaches us through an upstream MTA that already stamped an Authentication-Results header, that header is what we read.

Mailboxes #

POST/v1/mailboxes

Creates a mailbox and mints its address. Up to 100 per account.

BodyTypeNotes
namestringDefaults to "Inbox". Trimmed to 80 characters.
schemaarray or objectSee schemas.
webhook_urlstringAbsolute http or https. Anything else is 400 invalid_webhook_url.
slugstringOptional pretty alias. Derived from the name if you omit it; dropped silently on a collision inside your account, because it is a convenience and not an identifier.
forward_tostringOptional address to forward the original message to.
curl -X POST "$MAILMINT_URL/v1/mailboxes" \
  -H "Authorization: Bearer $MAILMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Invoices",
        "webhook_url": "https://your-app.example/hooks/mailmint",
        "schema": { "invoice_number": "string", "total": "number", "due_date": "date" } }'
201 Created
{
  "mailbox": {
    "id": "mbx_01JQ8Y7X6W5V4U3T2S1R",
    "name": "Invoices",
    "address": "k7m2xq4h9bwz@parse.example.com",
    "alias": "invoices.k7m2xq4h9bwz@parse.example.com",
    "token": "k7m2xq4h9bwz",
    "slug": "invoices",
    "schema": [ … ],
    "schema_version": 1,
    "webhook_url": "https://your-app.example/hooks/mailmint",
    "webhook_secret": "b3f1…",
    "webhooks": [
      { "id": "whe_4f1c…", "url": "https://your-app.example/hooks/mailmint",
        "description": null, "active": true, "secret": "b3f1…",
        "last_status": null, "last_delivered_at": null,
        "consecutive_failures": 0, "disabled_reason": null }
    ],
    "forward_to": null,
    "paused": false,
    "created_at": "2026-08-25T09:10:00.000Z"
  }
}

webhook_secret is generated for you and returned on create, fetch and update. It is the only thing that lets you prove a delivery came from MailMint. Verify it.

GET/v1/mailboxes

Returns { "data": [ … ], "inbound_domain": "parse.example.com" }. The inbound_domain is echoed so a client never has to hard-code it.

GET/v1/mailboxes/{id}

One mailbox, including webhook_secret.

PATCH/v1/mailboxes/{id}

Accepts name, schema, webhook_url and webhook_secret. Any change to the schema increments schema_version; the previous versions are kept, so a re-parse can name one.

This is the endpoint that makes the tuning loop possible. Parseur’s own developer documentation states you cannot create or update templates programmatically, so change schema → re-parse → compare is not scriptable anywhere else in this category.

DELETE/v1/mailboxes/{id}

Soft-deletes the mailbox. The address stops accepting mail.

GET/v1/messages #

Paged newest-first. Returns the summary shape, not the full message object.

QueryEffect
mailbox_idRestrict to one mailbox.
sinceISO-8601 timestamp. A value that is not a date is 400 invalid_since.
statusFilter by message status.
needs_reviewAnything but false restricts to messages a human should look at. Backed by its own partial index — this is a first-class query, not a client-side scan.
flagExactly one flag string, e.g. arithmetic_mismatch or low_confidence:total.
view=reviewAdds an issues array per row: which flag, which field, the value, the confidence and the evidence.
limit1–200, default 25.
cursorSee pagination.
curl "$MAILMINT_URL/v1/messages?needs_review=true&view=review&limit=50" \
  -H "Authorization: Bearer $MAILMINT_API_KEY"
200 OK
{
  "data": [
    {
      "id": "msg_01JQ8Z3K4M5N6P7Q8R9S",
      "mailbox_id": "mbx_01JQ8Y7X6W5V4U3T2S1R",
      "received_at": "2026-08-25T09:14:03.221Z",
      "from": "billing@acme.example",
      "subject": "Invoice INV-2291 from Acme Ltd",
      "size": 48213,
      "status": "parsed",
      "needs_review": true,
      "flags": ["low_confidence:due_date"],
      "spam_score": 0.4,
      "fields": { … },
      "attachments": [
        { "id": "att_…", "filename": "invoice-2291.pdf", "content_type": "application/pdf",
          "size": 48213, "extracted": null }
      ],
      "issues": [
        { "flag": "low_confidence:due_date", "field": "due_date", "value": "2026-09-08",
          "confidence": 0.55, "source": "llm", "evidence": "08/09/2026" }
      ]
    }
  ],
  "next_cursor": "msg_01JQ8Z3K4M5N6P7Q8R9R"
}

GET/v1/messages/{id} #

The full message object. Takes ?include=attachments, ?include=extracted_text and ?exclude=extracted, which may be combined as comma-separated lists.

curl "$MAILMINT_URL/v1/messages/msg_01JQ8Z3K4M5N6P7Q8R9S?include=attachments" \
  -H "Authorization: Bearer $MAILMINT_API_KEY"

404 message_not_found when the id does not exist on this account — a message belonging to somebody else is indistinguishable from one that never existed.

GET/v1/messages/{id}/raw #

The original RFC822 bytes, exactly as they arrived, as message/rfc822 with a .eml filename. This is the byte-for-byte input a re-parse replays, which is what makes a re-parse the real message rather than a re-reading of our own earlier output.

curl "$MAILMINT_URL/v1/messages/msg_01JQ8Z3K4M5N6P7Q8R9S/raw" \
  -H "Authorization: Bearer $MAILMINT_API_KEY" -o message.eml

410 raw_unavailable once the raw bytes have aged out, or if the message was over the size cap and never stored raw. The parsed JSON is still there either way.

GET/v1/attachments/{id} #

The bytes, with the original Content-Type and filename. 410 attachment_unavailable once the blob has aged out or if it was over the 10 MB storage cap — the attachment’s metadata and SHA-256 remain in the message either way.

Re-parsing #

The case this exists for: a sender changes their invoice layout, an automation quietly starts producing nulls, and somebody notices a fortnight later. Zapier’s staff answer to the same situation is verbatim “there is no way to replay them”; Mailparser reaches back 300 messages and does not keep the original bytes at all. MailMint keeps the bytes, so the fix is a two-minute loop instead of a three-day incident.

POST/v1/messages/{id}/reparse

BodyEffect
schemaA one-off schema for this run. Nothing is saved to the mailbox — this is how you try a change against yesterday’s real mail before committing to it.
schema_versionRe-run against a specific stored version, which is the only honest way to reproduce a result a customer is asking about.
deliverDefaults to false. Set it to fire the webhook again.
curl -X POST "$MAILMINT_URL/v1/messages/msg_01JQ8Z3K4M5N6P7Q8R9S/reparse" \
  -H "Authorization: Bearer $MAILMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "schema": [ { "name": "total", "type": "number", "hint": "labelled Amount Due" } ] }'

Returns the re-parsed message object. The event it emits is message.reparsed, not message.parsed, so a poller can tell the two apart.

POST/v1/mailboxes/{id}/reparse

Re-runs a whole mailbox’s stored history as a background job. Answers 202 immediately with a job id and a poll URL.

BodyDefaultEffect
dry_runfalseWrites nothing. Produces the diff list and stops. Always do this first.
redeliverfalseFire webhooks for every changed message. Separate from re-parsing on purpose: a tuning session that quietly re-fires a month of deliveries is how you get duplicate rows in somebody else’s database.
schema / schema_versionAs above.
since / untilISO-8601 bounds on received_at.
status · needs_review · flagNarrow the selection the same way GET /v1/messages does.
limit500Up to 5,000 messages per job.
# 1. See what would change, without changing anything.
curl -X POST "$MAILMINT_URL/v1/mailboxes/mbx_01JQ8Y7X6W5V4U3T2S1R/reparse" \
  -H "Authorization: Bearer $MAILMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "dry_run": true, "since": "2026-08-01T00:00:00Z" }'
202 Accepted
{
  "job_id": "rpj_01JQ8Z…",
  "mailbox_id": "mbx_01JQ8Y7X6W5V4U3T2S1R",
  "status": "queued",
  "dry_run": true,
  "redeliver": false,
  "total": 412,
  "done": 0,
  "changed": 0,
  "failed": 0,
  "diffs": [],
  "diffs_truncated": false,
  "poll": "https://…/v1/reparse/rpj_01JQ8Z…"
}

GET/v1/reparse/{job_id} #

Progress plus diffs — up to 200 entries saying which message changed and how. diffs_truncated tells you when the list hit that cap, so a short diff list can never be mistaken for a small change.

POST/v1/parse #

The stateless endpoint. Parse an email you already have, without an address and without storing anything — no message row, no raw bytes, no attachment blobs, nothing to delete afterwards. It is how you try MailMint before signing up for an address, and it is what the n8n Parse → Parse Email operation calls.

BodyNotes
raw_mimeA whole RFC822 message, either as plain text or base64. Up to 25 MB.
subject · text · htmlThe alternative to raw_mime, for when another node already split the message up.
schemaThe fields you want. Omit it and you still get detected, tables, decoded headers and a cleaned body.

Send neither form and you get 400 missing_input, with an example in the hint.

curl -X POST "$MAILMINT_URL/v1/parse" \
  -H "Authorization: Bearer $MAILMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "Invoice INV-2291 from Acme Ltd",
    "text": "Invoice INV-2291\n\nTotal: $31.50\nDue: Sep 8, 2026\n",
    "schema": [
      { "name": "invoice_number", "type": "string", "description": "the invoice or reference number" },
      { "name": "total",          "type": "number", "description": "grand total including tax" },
      { "name": "due_date",       "type": "date",   "description": "when payment is due" }
    ]
  }'

The response is the message object with id, mailbox and raw_url all null, and every attachment’s url null too — because nothing was stored, there is nothing to fetch later.

Set a generous client timeout. A schema that asks for an array roughly triples the model’s output: on a measured real invoice the four scalar fields came back in about 2–3 seconds and adding line_items took the same call to about 8.8 seconds. Fields the deterministic layer can resolve on its own never reach a model at all, so a tighter schema is genuinely faster.

GET/v1/events #

The polling feed, and what the n8n trigger lives on when it cannot be reached by webhook.

curl "$MAILMINT_URL/v1/events?cursor=1421&limit=50" \
  -H "Authorization: Bearer $MAILMINT_API_KEY"
200 OK
{
  "events": [
    {
      "id": 1422,
      "type": "message.parsed",
      "cursor": "1422",
      "created_at": "2026-08-25T09:14:03.400Z",
      "message": { … the full message object … }
    }
  ],
  "next_cursor": "1422",
  "has_more": false
}
QueryNotes
cursorPass back the previous next_cursor. Omit it to start at the beginning of the retained window. Anything that is not a non-negative number is 400 invalid_cursor.
mailbox_idRestrict the feed to one mailbox.
limit1–200, default 50.

The cursor is a strictly monotonic integer, not a timestamp — a timestamp cursor silently skips the second of two events written in the same millisecond. It is opaque; treat it as a string you store and hand back.

next_cursor is returned unchanged when there is nothing new, so an idle poller does not have to special-case an empty page. has_more is true when the page was filled, which is your signal to poll again immediately rather than waiting for the next tick.

Event types: message.parsed for new mail, message.reparsed when a re-parse produced it. Events are kept for 7 days; a cursor older than that resumes at the start of the retained window rather than failing.

POST/v1/test/deliver #

Injects a message into a mailbox exactly as if it had arrived from the internet — same code path as real inbound mail. It is stored, parsed, given an id, added to the event feed and delivered to your webhook. This is how you test the whole pipeline before any DNS exists.

BodyNotes
mailbox_idRequired.
raw_mimeA real message, plain or base64. The honest way to test something you already have on disk.
from · subject · textUsed to build a simple message when you do not send raw_mime.
deliverDefaults to true. Set false to store and parse without firing the webhook.

It answers synchronously — 201 with the full parsed message — because somebody who just clicked “send a test” wants the JSON on the screen, not a promise about it. Sending the same Message-ID twice returns 200 with the existing message rather than creating a duplicate, which is the same rule real inbound mail follows.

GET/v1/usage #

200 OK
{
  "plan": { "id": "free", "name": "Free", "quota": 300, "price_usd": 0 },
  "period_start": "2026-08-01T00:00:00.000Z",
  "used": 41,
  "remaining": 259,
  "messages": { "this_month": 41, "last_24h": 6, "needs_review": 3, "stored": 41 },
  "mailboxes": 2,
  "retention_days": 30,
  "key_mode": "live"
}

This is also the endpoint the n8n credential test calls, which is why a valid key gives you a green tick in n8n before you build anything.

GET/healthz #

No authentication. Useful for exactly one thing besides uptime checks: finding out whether the deployment you are pointed at has an inbound domain configured.

{ "ok": true, "parser": true, "inbound_domain": "parse.example.com",
  "internal_api": true, "billing": false }

Webhooks #

Set webhook_url on a mailbox and every parsed message is POSTed to it. The body is byte-for-byte the same message object that GET /v1/messages/{id} returns — one function builds it, so that promise is actually true rather than aspirational.

the request we send
POST /hooks/mailmint HTTP/1.1
content-type: application/json
user-agent: MailMint-Webhook/1
x-mailmint-event: message.parsed
x-mailmint-delivery: dlv_01JQ8Z3K4M5N6P7Q8R9S
x-mailmint-timestamp: 1787648043
x-mailmint-signature: t=1787648043,v1=6a1f…c07b

{ "id": "msg_01JQ8Z3K4M5N6P7Q8R9S", … }
HeaderMeaning
x-mailmint-eventmessage.parsed
x-mailmint-deliveryThe delivery id. Stable across retries of the same delivery, so it is the right idempotency key.
x-mailmint-timestampThe same unix timestamp that is inside the signature, for convenience. Do not trust this one — verify against the t= inside the signature header, which is the value that is actually signed.
x-mailmint-endpointWhich endpoint this delivery belongs to, when it was queued for one.
x-mailmint-signaturet=<unix>,v1=<hex>

The signature, exactly #

signed_payload = "<t>" + "." + <the raw request body, byte for byte>
v1             = hex( HMAC_SHA256( key = webhook_secret, message = signed_payload ) )
header         = "t=" + <t> + ",v1=" + v1

Three things decide whether your verification is correct:

  1. The raw body, not a re-serialisation. If your framework parses the JSON and you re-encode it to verify, key order and whitespace will differ and every signature will fail. Capture the bytes before parsing.
  2. The timestamp is inside the signed string. That is what makes a captured request non-replayable: a receiver rejects anything older than its tolerance, and an attacker cannot re-stamp it without the secret. Reject a t more than about 5 minutes from your own clock.
  3. Compare in constant time. === on a MAC leaks how far the attacker got.

A worked verification #

Express — note express.raw, not express.json
const crypto = require('node:crypto');
const express = require('express');

const app = express();
const SECRET = process.env.MAILMINT_WEBHOOK_SECRET;   // from the mailbox object
const TOLERANCE_SECONDS = 300;

// express.raw gives req.body as a Buffer — the exact bytes we sent.
// Parsing the JSON first and re-encoding it will NOT verify.
app.post('/hooks/mailmint', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('x-mailmint-signature') || '';

  // "t=1787648043,v1=6a1f…"  ->  { t: '1787648043', v1: '6a1f…' }
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.split('=').map((s) => s.trim())),
  );
  if (!parts.t || !parts.v1) return res.status(400).send('malformed signature');

  // 1. Freshness. The timestamp is signed, so this is not decoration.
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) {
    return res.status(400).send('stale signature');
  }

  // 2. Recompute over "<t>.<raw body>".
  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${parts.t}.${req.body.toString('utf8')}`)
    .digest('hex');

  // 3. Constant-time compare.
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1, 'hex');
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send('bad signature');
  }

  const message = JSON.parse(req.body.toString('utf8'));

  // Answer fast. Anything slow belongs on a queue — we time out at 10s.
  res.sendStatus(200);

  if (message.needs_review) {
    console.warn(message.id, 'needs review:', message.flags);
  } else {
    save(message.fields.invoice_number.value, message.fields.total.value);
  }
});

To check your implementation without waiting for mail, create a mailbox, point its webhook_url at your endpoint and call POST /v1/test/deliver. That is a real delivery through the real signing path.

Retries #

AttemptDelay after the previous failure
1immediately
230 seconds
32 minutes
410 minutes
51 hour
66 hours
  • Six attempts, then the delivery is marked failed. Each attempt has a 10-second timeout.
  • Any 2xx is success. Anything else, or a timeout, retries — except a 4xx, which means you understood us and said no. Retrying cannot change that, and hammering a 404 for six hours only fills your logs with our noise. 408 and 429 are the two 4xx codes that genuinely mean “later”, so those do retry.
  • The queue lives in the database, not in memory, so a redeploy mid-flight does not lose a delivery.
  • The body is rebuilt at each attempt from the current stored result — so if you re-parsed and fixed a field between attempts, the retry carries the corrected version rather than the one that failed.
  • x-mailmint-delivery is stable across attempts. Use it to make your handler idempotent.

Retrying a failed webhook is a paid add-on at Mailparser — $2.91–$3.49 a month, and not included at all below their Business plan. It is not an add-on here.

Several endpoints on one mailbox #

A single webhook_url is a shared mutable global. Two n8n MailMint Trigger nodes pointed at the same mailbox would overwrite each other’s URL on registration, and deactivating one workflow would silently delete the other’s delivery. So a mailbox carries a list of endpoints, each with its own id, its own secret and its own health.

RouteDoes
POST/v1/mailboxes/{id}/webhooks Add one. Body: url (required), description, secret (optional — one is generated if you omit it, and must be at least 16 characters if you supply it). The secret is returned here and never again, like an API key.
GET/v1/mailboxes/{id}/webhooks List them, with health. Secrets are not included.
GET/v1/webhooks/{id} One endpoint.
PATCH/v1/webhooks/{id} url, description, secret, active. Setting "active": true also clears an auto-disable and resets its counter.
DELETE/v1/webhooks/{id} Remove it. Deleting one cannot touch another.
curl -X POST "$MAILMINT_URL/v1/mailboxes/mbx_01JQ8Y7X6W5V4U3T2S1R/webhooks" \
  -H "Authorization: Bearer $MAILMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://your-app.example/hooks/mailmint", "description": "billing pipeline" }'
201 Created
{
  "webhook": {
    "id": "whe_4f1c...",
    "mailbox_id": "mbx_01JQ8Y7X6W5V4U3T2S1R",
    "url": "https://your-app.example/hooks/mailmint",
    "description": "billing pipeline",
    "active": true,
    "secret": "b3f1...",            // shown once, like an API key
    "created_at": "2026-08-25T09:10:00.000Z",
    "last_status": null,
    "last_delivered_at": null,
    "last_error": null,
    "consecutive_failures": 0,
    "disabled_at": null,
    "disabled_reason": null
  }
}
  • Every active endpoint gets its own delivery row, with its own retry schedule. A receiver that is down does not hold up one that is up.
  • Each endpoint signs with its own secret, so rotating one workflow’s secret cannot invalidate another’s.
  • An endpoint that keeps failing is switched off. After 10 consecutive deliveries have exhausted all six of their attempts — a receiver that has been gone for days, not a hiccup — the endpoint is disabled, and disabled_at and disabled_reason say so. Re-enable it with PATCH {"active": true}.
  • mailbox.webhook_url still works: it reads and writes the first endpoint, so anything already written against it keeps working. The list is the model; the column is the convenience.

Pagination & cursors #

Two cursors, and they are not the same kind of thing.

GET /v1/messagesGET /v1/events
Ordernewest firstoldest first
Cursor isthe last message id on the pagea monotonic integer event id
Default limit2550
Maximum limit200200
End of datanext_cursor is null has_more is false; next_cursor comes back unchanged
Use it forbrowsing and back-fillinga durable subscription

Message ids are sortable, so ?cursor= is a strict “older than this id” condition with no numeric offset to drift as new mail arrives mid-page. Both cursors are opaque: store the string, hand it back, do not parse it.

draining a page at a time
cursor=""
while :; do
  page=$(curl -s "$MAILMINT_URL/v1/messages?limit=100&cursor=$cursor" \
           -H "Authorization: Bearer $MAILMINT_API_KEY")
  echo "$page" | jq -c '.data[]'
  cursor=$(echo "$page" | jq -r '.next_cursor // empty')
  [ -z "$cursor" ] && break
done

Errors #

Every error is a JSON object with a stable machine code, a one-line human message, usually a hint that says what to change, sometimes a link to the exact docs anchor, and always the request_id.

{
  "error": {
    "code": "invalid_schema",
    "message": "schema[1].type \"nummber\" is not a field type.",
    "hint": "Accepted types: string, number, integer, boolean, date, datetime, email, url, phone, currency, enum, array, object.",
    "docs": "https://…/docs#schema",
    "request_id": "req_9f2c8b…"
  }
}

Branch on code, never on the message text. Codes are stable; the sentences are not.

StatusCodeMeans
400invalid_jsonThe body is not valid JSON.
400invalid_schemaA field definition is wrong. The message names the path.
400missing_inputPOST /v1/parse with neither raw_mime nor subject/text/html.
400too_largeThe message exceeds the 25 MB raw cap.
400invalid_webhook_urlNot absolute, or not http/https.
400invalid_cursor · invalid_since · invalid_dateA cursor that is not a cursor, or a date that is not a date.
400weak_webhook_secretThe secret you supplied is too short to be worth signing with.
401missing_api_keyNo key on the request.
401invalid_api_keyWrong shape, unknown, or revoked.
402quota_exceededOut of parses for the month. details carries the plan, the usage and the limit. Inbound mail is unaffected.
404message_not_found · mailbox_not_found · attachment_not_found · reparse_job_not_found · schema_version_not_found · webhook_not_found No such object on this account.
400missing_urlA webhook endpoint was created without a url.
404unknown_endpointNo such route. The hint points here.
409too_many_mailboxes100 per account.
409last_keyRevoking your only key would lock you out.
410raw_unavailable · attachment_unavailable Those bytes have aged out, or were never stored because they were over the cap. The parsed JSON remains.
413request_too_largeThe HTTP body is over 40 MB.
429rate_limitedSee limits. Retry-After says how long.
502parse_failedParsing threw. On a re-parse the stored message is untouched and keeps its previous result.
500internal_errorOurs. Quote the request_id.

A failed parse is not an error response. A message that could not be extracted cleanly still arrives, with needs_review: true and flags naming what went wrong. HTTP errors are for requests we could not carry out; flags are for results you should not trust. Do not conflate them.

Limits & rate limits #

LimitValueWhat happens at the edge
API requests240/minute per account, burst 40429 rate_limited with Retry-After
HTTP request body40 MB413 request_too_large
Raw message25 MB400 too_large on the API; inbound mail over the cap is received but not stored raw
Stored attachment10 MBMetadata and SHA-256 kept, bytes not stored, flag attachment_too_large
Schema width60 fields400 invalid_schema
Schema depth2 levels of objects400 invalid_schema
Enum options200Truncated
Table rows5,000truncated: true plus flag table_truncated
Mailboxes100 per account409 too_many_mailboxes
Page size200Clamped, not refused
Re-parse job5,000 messages, 200 diffs returneddiffs_truncated: true
Webhook timeout10 s per attemptCounts as a failure and retries

Rate-limit state is reported on every response:

X-RateLimit-Limit: 240
X-RateLimit-Burst: 40
X-RateLimit-Remaining: 37
Retry-After: 3          # only on a 429

Inbound mail is never rate limited. The sender is a stranger’s mail server, and refusing their message because a customer was busy is a bounce we would have to explain to somebody who has no relationship with us. The limit above guards the API only.

Retention #

Three different things are kept for three different lengths of time, deliberately.

WhatKept forWhy that long
The parsed message and its metadata30 days by default Long enough to notice something went wrong and page back through it.
The original RFC822 bytesLonger, and set per plan A re-parse replays these, so they have to outlive the moment somebody notices a sender changed a layout. That is weeks, not days.
Attachment bytesShorter, and set per plan They are the bulk of the storage and are not needed to re-parse a body, so they go first.
Events7 daysA polling window, not an archive.

GET /v1/usage reports the retention_days in force for your account. Once raw bytes are gone, raw_url is null and GET /v1/messages/{id}/raw answers 410 raw_unavailable — the parsed JSON remains either way.

POST /v1/parse stores nothing at all. No message row, no raw bytes, no attachment blobs, no event. If retaining mail is a problem for you, that is the endpoint to use, and its statelessness is a promise rather than a default we might change.

n8n node #

n8n-nodes-mailmint ships two nodes — MailMint (11 actions) and MailMint Trigger — under MIT, with zero runtime dependencies. The full walkthrough, with screenshots of it running in a real n8n, is on the n8n page.

It is not published to npm yet, so it cannot be installed from Settings → Community Nodes, and n8n Cloud will not accept it until it is also verified. An HTTP Request node pointed at POST /v1/parse does the same job from any n8n today, Cloud included.

ResourceOperationCalls
ParseParse EmailPOST /v1/parse
MessageGetGET /v1/messages/{id}
Get ManyGET /v1/messages
Get RawGET /v1/messages/{id}/raw
Download AttachmentGET /v1/attachments/{id}
ReparsePOST /v1/messages/{id}/reparse
MailboxCreatePOST /v1/mailboxes
Get ManyGET /v1/mailboxes
UpdatePATCH /v1/mailboxes/{id}
Reparse MessagesPOST /v1/mailboxes/{id}/reparse
DeleteDELETE /v1/mailboxes/{id}
TriggerWebhook modeSets the mailbox webhook_url on activation and verifies every x-mailmint-signature
TriggerPolling modeGET /v1/events

What is not built yet #

Kept here rather than left for you to discover.

  • The hosted inbound address is not live. No domain has been bought. Addresses are minted and reserved, and nothing external can reach them yet. POST /v1/parse and POST /v1/test/deliver both work today, and neither needs DNS.
  • Attachment content extraction is not wired into the parse pipeline. The extractor package is written; attachments[].extracted is reserved for its output and is not populated yet.
  • n8n-nodes-mailmint is not on npm.
  • The confidence calibration curve is measured but small. The published table covers 95 labelled field slots across 41 messages, and it is enough to show the pipeline is not over-confident — nothing in the 0.9+ bucket was wrong — and nowhere near enough to fit a reliability curve. That needs a corpus an order of magnitude larger, with deliberately hard cases.
  • Non-Latin scripts have no deterministic rules. The label synonym tables cover English, German, French, Spanish, Italian, Dutch and Portuguese. A Japanese receipt needs the model for everything, which is slower and carries a lower ceiling on confidence.
  • Truly ambiguous dates stay ambiguous. 08/09/2026 from a sender with no other date in the document resolves day-first at a confidence around 0.55. That is a coin flip with an honest number on it, not a solution — and it is exactly what needs_review is for.
  • Prompt injection is not solved. Any product that pipes attacker-controlled email bodies into a model has this problem. The evidence-substring rule, the rule/model agreement check and the arithmetic reconciliation are partial structural mitigations — an instruction embedded in a body cannot manufacture any of them — but they are not a control. Treat parsed output as data and check needs_review before acting on it.
  • There is no pricing, no SLA and no uptime history.