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 — k7m2xq4h9bwz@parse.<domain> — and that address only exists once a domain has been pointed at the service. No domain has been configured yet, so no hosted address exists. Paths 1, 2 and 3 below all work without one, 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 #

There is no public hosted deployment yet, so there is no URL we can honestly print here as if it were live. Set MAILMINT_URL to whichever MailMint you are talking to:

# 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.

the whole quickstart, in one command
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:

response · fields
"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:

  • evidence is 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 flagged hallucinated_evidence:<field>.
  • source says which layer answered. rule means a deterministic rule found it without a model at all; rule+llm means both layers agreed independently.
  • confidence is computed by us, never taken from the model. See how it is computed.
  • A value that is not in the message is null with confidence 0 and "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" }
    ]
  }'
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",
                    "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.

  1. Add an Email Trigger (IMAP) node and point it at your mailbox. Any Format works.
  2. Add a MailMint node after it. It opens on Parse → Parse Email with Input: Automatic, which is already correct.
  3. Under Fields, click Add Field and fill in a name, a type and a sentence of description.
  4. Execute step.

n8n-nodes-mailmint is not published to npm yet, so it cannot be installed from Settings → Community Nodes today, and n8n Cloud will not accept it until it is also verified. Until then, an HTTP Request node pointed at POST /v1/parse does the same job from any n8n, Cloud included. 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 is the one thing on this page that is not available yet. Being precise about why:

Inbound mail arrives through Cloudflare Email Routing into an Email Worker that streams the raw message to the API. That is free at any volume we will plausibly reach, accepts messages up to 25 MiB, and a single catch-all rule covers every customer address. But it requires a domain whose nameservers point at Cloudflare, and no domain has been bought. Until one is:

  • GET /healthz reports the configured inbound_domain, and mailbox addresses are built from it. The address in a POST /v1/mailboxes response is well-formed and reserved for you; it just has no MX behind it yet.
  • Nothing else changes when it does. The 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/deliver does today.

MailMint also ships its own SMTP server (packages/smtpd) as the escape hatch for a self-hosting or air-gapped deployment, and adapters for Mailgun, CloudMailin and a generic webhook, so a customer already running one of those does not have to move. Neither is the default path.

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 #

Field typesAll thirteen types, and exactly what each one coerces to.
ConfidenceWhat the number is built from, and why it is not the model’s opinion.
Flags & needs_reviewEvery flag, what raises it, and which ones set needs_review.
Webhook signaturesThe exact HMAC construction, verified in Node and Python.
PollingThe cursor feed the n8n trigger lives on.
Re-parsingFix a schema, dry-run it over last month, then run it for real.