Quickstart
The shortest true path from nothing to structured JSON. The first one takes a single
curl and about sixty seconds, and it needs no email address, no DNS record and no waiting for
mail to arrive.
Read this first, because it decides which path you want. MailMint’s headline
feature is a hosted inbound address — <token>@smooth-operator.online — and it is
live: sign up and the address on your dashboard accepts mail immediately, taken by MailMint’s own
SMTP server (details below). You do not have to start there, though. Paths 1,
2 and 3 need no address, no DNS record and no waiting for mail to arrive, and they use the same parser
and return the same shape.
Get an API key #
Create an account at /signup. The key is shown once, on the dashboard, and starts
with mm_live_. A key beginning mm_test_ behaves identically but is never counted
against your quota — use that one in CI.
Keys cannot be read back after they are created. Put it in an environment variable now:
export MAILMINT_API_KEY="mm_live_..."
Never paste a real key into a page, a screenshot, an issue or a shared notebook. Every example on this
site uses a placeholder, and every email address is a deliberately fake .example address.
The base URL #
The hosted service is https://mailmint.app.mintapis.com — the site you are reading. Set
MAILMINT_URL to whichever MailMint you are talking to, and every command on this page works
unchanged against either one:
# the hosted service - this is the one you are reading right now
export MAILMINT_URL="https://mailmint.app.mintapis.com"
# or running it yourself, from the repository
export MAILMINT_URL="http://127.0.0.1:3100"
Every path is under /v1, every call carries
Authorization: Bearer $MAILMINT_API_KEY, and GET /healthz needs no auth and tells
you whether the parser loaded and whether an inbound domain is configured.
1 · One curl, nothing stored #
POST /v1/parse is the stateless endpoint. Give it an email — raw MIME, or just a subject and
a body — and a schema, and it returns the parsed result. It writes nothing to the database, so there is no
message to fetch afterwards and nothing to delete.
curl -s -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" }
]
}'
What comes back is the full result object. The part you came for:
"fields": {
"invoice_number": { "value": "INV-2291", "confidence": 0.97, "source": "rule+llm",
"evidence": "Invoice INV-2291" },
"total": { "value": 31.5, "confidence": 0.97, "source": "rule",
"evidence": "Total: $31.50" },
"due_date": { "value": "2026-09-08", "confidence": 0.97, "source": "rule",
"evidence": "Due: Sep 8, 2026" }
},
"flags": [],
"needs_review": false,
"parse": {
"model": "deepseek-ai/DeepSeek-V4-Flash-0731-TEE",
"llm_used": true,
"timings_ms": { "total": 4735, "mime": 22, "deterministic": 14, "llm": 4611, "persist": 0 }
}
Four things are worth noticing before you go any further, because they are the whole product:
evidenceis a verbatim substring of the input. If the model quotes something that is not really in the message, the confidence is cut and capped and the field is flaggedhallucinated_evidence:<field>.sourcesays which layer answered.rulemeans a deterministic rule found it without a model at all;rule+llmmeans both layers agreed independently.confidenceis computed by us, never taken from the model. See how it is computed.- A value that is not in the message is
nullwith confidence0and"source": "none". It is never invented and never a placeholder string.
On latency. The 4,735 ms above is a real end-to-end parse of a real invoice
email. Almost all of it is the model call — MIME parsing took 22 ms and the deterministic rules layer
took 14 ms. Fields the rules layer can resolve on its own never reach a model at all, which is why a
tight schema is faster as well as more accurate.
Adding fields #
A schema is a list of field definitions. The only required key is name; type
defaults to string. description is the single biggest lever on accuracy — write it
the way you would explain the field to a new colleague — and hint is where you put the label
the mail actually uses.
{ "name": "total", "type": "number", "description": "grand total incl. tax",
"required": true, "hint": "labelled Total or Amount Due" }
There is also an object shorthand, which is what most people reach for first and which normalises to the same thing:
"schema": { "invoice_number": "string", "total": "number", "due_date": "date" }
Line items are 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" }
] } }
All thirteen types and their coercion rules are in the reference.
2 · A mailbox and a webhook #
A mailbox is one inbound address plus the schema and webhook that belong to it. Creating one needs no DNS and no domain — the address it hands back simply will not receive external mail until a domain is configured. Everything else about it works now, including the webhook.
curl -s -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": [
{ "name": "invoice_number", "type": "string" },
{ "name": "total", "type": "number" },
{ "name": "due_date", "type": "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",
"active": true, "secret": "b3f1…" } ],
"paused": false,
"created_at": "2026-08-25T09:10:00.000Z"
}
}
Keep webhook_secret. It is returned when you create or fetch a mailbox, and
it is the only thing that lets you prove a delivery really came from MailMint. (A mailbox can carry
several endpoints, each with its own secret;
webhook_url and webhook_secret are the first one.) Verifying it takes about six
lines — Node and Python examples are in the reference. Do not skip it and do not
improvise it.
Injecting a test message #
POST /v1/test/deliver puts a message through the mailbox exactly as if it had arrived from
the internet: it is stored, parsed against the mailbox schema, given an id, added to the event feed and
delivered to your webhook. This is how you test the whole pipeline end to end before any DNS exists.
curl -s -X POST "$MAILMINT_URL/v1/test/deliver" \
-H "Authorization: Bearer $MAILMINT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"mailbox_id": "mbx_01JQ8Y7X6W5V4U3T2S1R",
"from": "billing@acme.example",
"subject": "Invoice INV-2291 from Acme Ltd",
"text": "Invoice INV-2291\n\nTotal: $31.50\nDue: Sep 8, 2026\n"
}'
It answers 201 with the full parsed message, synchronously — because a person who just
clicked “send a test” wants the JSON on the screen, not a promise about it. You can also send
"raw_mime" instead, as plain RFC822 or base64, which is the honest way to test a message you
already have on disk. Set "deliver": false to parse and store without firing the webhook.
From here the rest of the API is available on a real message:
GET /v1/messages/:id, GET /v1/messages/:id/raw,
POST /v1/messages/:id/reparse and GET /v1/events.
3 · n8n, after a mailbox you already own #
If mail already reaches n8n — through the built-in Email Trigger (IMAP), through Gmail, through anything — put a MailMint node after it and it parses what arrives. No MailMint address, no DNS, no forwarding rule.
- Add an Email Trigger (IMAP) node and point it at your mailbox. Any Format works.
- Add a MailMint node after it. It opens on Parse → Parse Email with Input: Automatic, which is already correct.
- Under Fields, click Add Field and fill in a name, a type and a sentence of description.
- Execute step.
n8n-nodes-mailmint is on npm. On self-hosted n8n, install it from
Settings → Community Nodes or with npm install n8n-nodes-mailmint in
~/.n8n/nodes. n8n published version 0.1.2 in its verified registry on 1 September
2026, so it installs on n8n Cloud too. If community nodes are disabled, an HTTP Request
node pointed at POST /v1/parse does the same job. The full
walkthrough, with screenshots of the node running in a real n8n, is on the n8n page.
The hosted inbound address #
This is the feature the product is named for, and it works. Sign up, and the address on your
dashboard — <token>@smooth-operator.online — is live immediately.
Inbound mail is taken by MailMint’s own SMTP server (packages/smtpd), which is what
mx.smooth-operator.online points at. Because it sees the envelope and the raw RFC822 bytes, the
SPF, DKIM and DMARC verdicts in auth are computed on the message as received rather than
inferred afterwards. A check that cannot be evaluated says none rather than guessing.
GET /healthzreports the configuredinbound_domain, and mailbox addresses are built from it.- The path is the same either way: a message that arrives through the real MX is parsed by the same
code, returns the same object and fires the same webhook as
POST /v1/test/deliverdoes.
For a self-hosting or air-gapped deployment, packages/smtpd is the same server you can run
yourself, and there are adapters for Mailgun, CloudMailin and a generic webhook, so a customer already
running one of those does not have to move.
The IMAP connector #
packages/intake is a connector that logs into an existing IMAP mailbox, pulls new mail and
hands it to the API — the intake path that needs no DNS change at all. It has zero dependencies and it can
also detect a forwarding-confirmation code, which is what makes a Gmail auto-forward set-up survivable.
IMAP_HOST=imap.example.com IMAP_USER=you@example.com IMAP_PASS=… \
MAILBOX_TOKEN=k7m2xq4h9bwz \
MAILMINT_API_URL="$MAILMINT_URL" INTERNAL_SECRET=… \
npx mailmint-intake watch
This is an operator tool today, not a self-serve feature. It delivers through
POST /internal/deliver, which is guarded by the INTERNAL_SECRET shared with the
service — so it is something you run next to your own MailMint, not something you configure from the
dashboard. If you want IMAP mail parsed and you are not hosting MailMint yourself, use
path 3 or call POST /v1/parse from your own poller.
Where to go next #
needs_review.