Skip to content

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.

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 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 /healthz reports the configured inbound_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/deliver does.

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 #

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.