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.
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 #
| Shape | Returned by | What 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 summary | GET /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 mailbox | every /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"
| Prefix | Behaviour |
|---|---|
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.
| Form | Example | Routes to |
|---|---|---|
| Canonical | k7m2xq4h9bwz@parse.example.com | the mailbox |
| Slug alias | invoices.k7m2xq4h9bwz@parse.example.com | the same mailbox |
| Sub-address | k7m2xq4h9bwz+acme@parse.example.com | the 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.
"schema": [
{ "name": "total", "type": "number", "description": "grand total incl. tax",
"required": true, "hint": "labelled Total or Amount Due" }
]
"schema": { "invoice_number": "string", "total": { "type": "number", "hint": "labelled Total" } }
| Key | Type | Meaning |
|---|---|---|
name | string, required | Becomes 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. |
type | string | One of the thirteen field
types. Defaults to string. |
description | string, ≤ 500 chars | The single biggest lever on accuracy. Write it the way you would explain the field to a new colleague. |
hint | string, ≤ 300 chars | The label the mail actually uses, e.g.
"labelled Amount Due". |
required | boolean | A required field that is missing is still
null — it is flagged, never fabricated. |
options | array | enum only. Up to 200 values. |
items | object | array only: {"type": …}, plus
fields when the item type is object. |
fields | array | object 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.
| Type | You get back | Notes |
|---|---|---|
string | "INV-2291" | The text as written in the mail. |
number | 31.5 | Decimals allowed. |
integer | 3 | A whole number. |
boolean | true | |
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.
{
"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"
}
| Key | What it is |
|---|---|
id | Sortable, prefixed msg_. null from
POST /v1/parse, which stores nothing. |
received_at | When we accepted the message — not the sender’s
Date: header, which is in headers.date and may be wrong, or
null. |
status | parsed, or a state you can filter
GET /v1/messages by. |
envelope | What 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. |
headers | Decoded and unfolded, RFC 2047 encoded words resolved. Everything
we do not name explicitly is under headers.raw with lowercased keys. |
body.stripped_text | The body with the trailing quoted reply chain and the signature removed. This is usually what you want to extract from. |
auth | SPF, DKIM and DMARC. Documented in full below,
including why dkim: "body_altered" is a different thing from
dkim: "fail". |
tables | Deterministic table extraction. See below. |
detected | Always 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. |
fields | Your schema’s answers. {} when no schema is set, and
the message is flagged no_schema. |
parse.model | The model that answered, or null when the deterministic
layer resolved everything and no model ran at all. |
parse.cost | What 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_url | The 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:
- Evidence verification.
evidencemust 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 flaggedhallucinated_evidence:<field>. - Independent agreement. A deterministic rules layer and the model both run. When they
produce the same answer independently,
sourcebecomes"rule+llm"and the score reaches its ceiling. When they disagree, the rule’s value is kept, the confidence drops sharply, andrule_llm_disagreement:<field>is raised. No competitor runs both layers, so no competitor can detect this at all. - 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. - Type, format and enum validity after coercion.
- 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.
source | Means |
|---|---|
rule | A deterministic rule found it. No model was involved for this field. |
llm | Only the model found it. |
rule+llm | Both layers ran and agreed independently. The highest-trust source. |
header | Lifted from a message header. |
attachment | Read out of an attachment. |
none | Not 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 confidence | Values | Actually correct |
|---|---|---|
| 0.9 – 1.0 | 62 | 91.9 % |
| 0.7 – 0.9 | 29 | 82.8 % |
| 0.6 – 0.7 | 7 | 57.1 % |
| below 0.6 | 5 | 20.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:
- 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.
- 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. evidenceis 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.
| Flag | Raised when | Sets 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_mismatch | The line items do not add up to the stated total. | yes |
table_truncated | A table hit the row cap and is known to be short. | yes |
attachment_unreadable | An 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_schema | The mailbox has no schema, so fields is
{}. | no |
llm_unavailable | The model chain could not be reached. Deterministic results are still returned. | no |
truncated_body | The body exceeded the text cap and was cut. | no |
attachment_too_large | An attachment exceeded the storage cap. Its metadata is still recorded. | no |
spam_suspected | An 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 parameter | Effect |
|---|---|
?include=attachments | Inlines the bytes as content_base64. |
?include=extracted_text | Returns the full extracted text instead of the 2,000-character preview. |
?exclude=extracted | Drops 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 }
| Key | Values |
|---|---|
spf | pass · fail · softfail ·
neutral · none · temperror · permerror |
dkim | pass · fail · body_altered ·
none · temperror · permerror |
dmarc | pass · fail · none ·
temperror · permerror |
spam_score | A 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/simplecanonicalisation, which is the combination naive implementations get wrong. - On the hosted Cloudflare Email Routing path,
spfis reported asnone, because Email Routing gives the receiving worker no client IP and SPF cannot be evaluated without one. We reportnonerather than guessing at a result we did not compute. - When mail reaches us through an upstream MTA that already stamped an
Authentication-Resultsheader, that header is what we read.
Mailboxes #
POST/v1/mailboxes
Creates a mailbox and mints its address. Up to 100 per account.
| Body | Type | Notes |
|---|---|---|
name | string | Defaults to "Inbox". Trimmed to 80
characters. |
schema | array or object | See schemas. |
webhook_url | string | Absolute http or https.
Anything else is 400 invalid_webhook_url. |
slug | string | Optional 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_to | string | Optional 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" } }'
{
"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.
| Query | Effect |
|---|---|
mailbox_id | Restrict to one mailbox. |
since | ISO-8601 timestamp. A value that is not a date is
400 invalid_since. |
status | Filter by message status. |
needs_review | Anything 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. |
flag | Exactly one flag string, e.g. arithmetic_mismatch or
low_confidence:total. |
view=review | Adds an issues array per row: which flag, which field,
the value, the confidence and the evidence. |
limit | 1–200, default 25. |
cursor | See pagination. |
curl "$MAILMINT_URL/v1/messages?needs_review=true&view=review&limit=50" \
-H "Authorization: Bearer $MAILMINT_API_KEY"
{
"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
| Body | Effect |
|---|---|
schema | A 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_version | Re-run against a specific stored version, which is the only honest way to reproduce a result a customer is asking about. |
deliver | Defaults 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.
| Body | Default | Effect |
|---|---|---|
dry_run | false | Writes nothing. Produces the diff list and stops. Always do this first. |
redeliver | false | Fire 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_version | — | As above. |
since / until | — | ISO-8601 bounds on
received_at. |
status · needs_review · flag | — | Narrow the
selection the same way GET /v1/messages does. |
limit | 500 | Up 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" }'
{
"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.
| Body | Notes |
|---|---|
raw_mime | A whole RFC822 message, either as plain text or base64. Up to 25 MB. |
subject · text · html | The alternative to
raw_mime, for when another node already split the message up. |
schema | The 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" }
]
}'
import { readFile } from 'node:fs/promises';
const raw = await readFile('message.eml');
const res = await fetch(`${process.env.MAILMINT_URL}/v1/parse`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MAILMINT_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
raw_mime: raw.toString('base64'),
schema: [
{ name: 'invoice_number', type: 'string' },
{ name: 'total', type: 'number' },
{ name: 'line_items', type: 'array',
items: { type: 'object', fields: [
{ name: 'description', type: 'string' },
{ name: 'qty', type: 'integer' },
{ name: 'amount', type: 'number' },
] } },
],
}),
});
const result = await res.json();
if (result.needs_review) {
console.warn('a human should look at this:', result.flags);
}
console.log(result.fields.total.value, result.fields.total.confidence);
import base64, os, pathlib, requests
raw = pathlib.Path("message.eml").read_bytes()
res = requests.post(
f"{os.environ['MAILMINT_URL']}/v1/parse",
headers={"Authorization": f"Bearer {os.environ['MAILMINT_API_KEY']}"},
json={
"raw_mime": base64.b64encode(raw).decode(),
"schema": [
{"name": "invoice_number", "type": "string"},
{"name": "total", "type": "number"},
],
},
timeout=60,
)
res.raise_for_status()
result = res.json()
if result["needs_review"]:
print("a human should look at this:", result["flags"])
total = result["fields"]["total"]
print(total["value"], total["confidence"], total["evidence"])
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"
{
"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
}
| Query | Notes |
|---|---|
cursor | Pass 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_id | Restrict the feed to one mailbox. |
limit | 1–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.
| Body | Notes |
|---|---|
mailbox_id | Required. |
raw_mime | A real message, plain or base64. The honest way to test something you already have on disk. |
from · subject · text | Used to build a simple message
when you do not send raw_mime. |
deliver | Defaults 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 #
{
"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.
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", … }
| Header | Meaning |
|---|---|
x-mailmint-event | message.parsed |
x-mailmint-delivery | The delivery id. Stable across retries of the same delivery, so it is the right idempotency key. |
x-mailmint-timestamp | The 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-endpoint | Which endpoint this delivery belongs to, when it was queued for one. |
x-mailmint-signature | t=<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:
- 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.
- 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
tmore than about 5 minutes from your own clock. - Compare in constant time.
===on a MAC leaks how far the attacker got.
A worked verification #
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);
}
});
import hmac, hashlib, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["MAILMINT_WEBHOOK_SECRET"].encode() # from the mailbox object
TOLERANCE_SECONDS = 300
@app.post("/hooks/mailmint")
def mailmint_webhook():
header = request.headers.get("X-MailMint-Signature", "")
# "t=1787648043,v1=6a1f…" -> {"t": "1787648043", "v1": "6a1f…"}
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
abort(400, "malformed signature")
# 1. Freshness. The timestamp is signed, so this is not decoration.
try:
if abs(time.time() - int(t)) > TOLERANCE_SECONDS:
abort(400, "stale signature")
except ValueError:
abort(400, "malformed timestamp")
# 2. Recompute over "<t>.<raw body>". get_data() is the exact bytes we
# sent — do NOT use request.json here and re-serialise it.
body = request.get_data()
expected = hmac.new(SECRET, t.encode() + b"." + body, hashlib.sha256).hexdigest()
# 3. Constant-time compare.
if not hmac.compare_digest(expected, v1):
abort(401, "bad signature")
message = request.get_json()
if message["needs_review"]:
app.logger.warning("%s needs review: %s", message["id"], message["flags"])
else:
save(message["fields"]["invoice_number"]["value"],
message["fields"]["total"]["value"])
# Answer fast. Anything slow belongs on a queue — we time out at 10s.
return "", 200
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 #
| Attempt | Delay after the previous failure |
|---|---|
| 1 | immediately |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 1 hour |
| 6 | 6 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.
408and429are 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-deliveryis 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.
| Route | Does |
|---|---|
| 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" }'
{
"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_atanddisabled_reasonsay so. Re-enable it withPATCH {"active": true}. mailbox.webhook_urlstill 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/messages | GET /v1/events | |
|---|---|---|
| Order | newest first | oldest first |
| Cursor is | the last message id on the page | a monotonic integer event id |
Default limit | 25 | 50 |
Maximum limit | 200 | 200 |
| End of data | next_cursor is null |
has_more is false; next_cursor comes back
unchanged |
| Use it for | browsing and back-filling | a 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.
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.
| Status | Code | Means |
|---|---|---|
| 400 | invalid_json | The body is not valid JSON. |
| 400 | invalid_schema | A field definition is wrong. The message names the path. |
| 400 | missing_input | POST /v1/parse with neither
raw_mime nor subject/text/html. |
| 400 | too_large | The message exceeds the 25 MB raw cap. |
| 400 | invalid_webhook_url | Not absolute, or not
http/https. |
| 400 | invalid_cursor · invalid_since ·
invalid_date | A cursor that is not a cursor, or a date that is not a date. |
| 400 | weak_webhook_secret | The secret you supplied is too short to be worth signing with. |
| 401 | missing_api_key | No key on the request. |
| 401 | invalid_api_key | Wrong shape, unknown, or revoked. |
| 402 | quota_exceeded | Out of parses for the month.
details carries the plan, the usage and the limit. Inbound mail is unaffected. |
| 404 | message_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. |
| 400 | missing_url | A webhook endpoint was created without a
url. |
| 404 | unknown_endpoint | No such route. The hint points here. |
| 409 | too_many_mailboxes | 100 per account. |
| 409 | last_key | Revoking your only key would lock you out. |
| 410 | raw_unavailable · attachment_unavailable |
Those bytes have aged out, or were never stored because they were over the cap. The parsed JSON remains. |
| 413 | request_too_large | The HTTP body is over 40 MB. |
| 429 | rate_limited | See limits.
Retry-After says how long. |
| 502 | parse_failed | Parsing threw. On a re-parse the stored message is untouched and keeps its previous result. |
| 500 | internal_error | Ours. 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 #
| Limit | Value | What happens at the edge |
|---|---|---|
| API requests | 240/minute per account, burst 40 | 429 rate_limited with
Retry-After |
| HTTP request body | 40 MB | 413 request_too_large |
| Raw message | 25 MB | 400 too_large on the API; inbound mail over the cap is
received but not stored raw |
| Stored attachment | 10 MB | Metadata and SHA-256 kept, bytes not stored, flag
attachment_too_large |
| Schema width | 60 fields | 400 invalid_schema |
| Schema depth | 2 levels of objects | 400 invalid_schema |
| Enum options | 200 | Truncated |
| Table rows | 5,000 | truncated: true plus flag
table_truncated |
| Mailboxes | 100 per account | 409 too_many_mailboxes |
| Page size | 200 | Clamped, not refused |
| Re-parse job | 5,000 messages, 200 diffs returned | diffs_truncated: true |
| Webhook timeout | 10 s per attempt | Counts 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.
| What | Kept for | Why that long |
|---|---|---|
| The parsed message and its metadata | 30 days by default | Long enough to notice something went wrong and page back through it. |
| The original RFC822 bytes | Longer, 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 bytes | Shorter, and set per plan | They are the bulk of the storage and are not needed to re-parse a body, so they go first. |
| Events | 7 days | A 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.
| Resource | Operation | Calls |
|---|---|---|
| Parse | Parse Email | POST /v1/parse |
| Message | Get | GET /v1/messages/{id} |
| Get Many | GET /v1/messages | |
| Get Raw | GET /v1/messages/{id}/raw | |
| Download Attachment | GET /v1/attachments/{id} | |
| Reparse | POST /v1/messages/{id}/reparse | |
| Mailbox | Create | POST /v1/mailboxes |
| Get Many | GET /v1/mailboxes | |
| Update | PATCH /v1/mailboxes/{id} | |
| Reparse Messages | POST /v1/mailboxes/{id}/reparse | |
| Delete | DELETE /v1/mailboxes/{id} | |
| Trigger | Webhook mode | Sets the mailbox webhook_url on activation and
verifies every x-mailmint-signature |
| Trigger | Polling mode | GET /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/parseandPOST /v1/test/deliverboth work today, and neither needs DNS. - Attachment content extraction is not wired into the parse pipeline. The extractor
package is written;
attachments[].extractedis reserved for its output and is not populated yet. n8n-nodes-mailmintis 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/2026from 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 whatneeds_reviewis 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_reviewbefore acting on it. - There is no pricing, no SLA and no uptime history.