Email to webhook
A supplier, a shop or a web form sends mail. You want it to arrive at your own endpoint as an HTTP request with JSON you can trust — not in an inbox somebody has to poll with IMAP. This page walks through exactly what MailMint sends, how to verify it, and what happens when your endpoint is down.
From mail to POST #
- You create a mailbox. It gets its own address,
<token>@smooth-operator.online, whose MX record points atmx.smooth-operator.online. Forward mail to it, or give it out directly. - Mail arrives over SMTP. MailMint verifies SPF, DKIM and DMARC itself against live DNS, keeps the original bytes, and parses the message — against your schema, if the mailbox has one.
- The result is POSTed to every active webhook endpoint on the mailbox, signed with that endpoint’s secret, and also written to the event feed.
The body of the POST is byte-for-byte the same message object GET /v1/messages/{id} returns
— one function builds both.
One call to get an address #
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" } }'
The 201 response contains the mailbox’s address, a slug
alias (invoices.k7m2xq4h9bwz@…) and the webhook_secret. Plus-addressing
works too: k7m2xq4h9bwz+acme@… lands in the same mailbox and the tag survives on
envelope.to, so one mailbox can tell several senders apart. Up to 100 mailboxes per account.
No code at all? The n8n trigger node creates the webhook registration itself when you activate a workflow.
The request you receive #
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",
"mailbox": { "id": "mbx_01JQ8Y…", "address": "k7m2xq4h9bwz@smooth-operator.online", "name": "Invoices" },
"envelope": { "from": "billing@acme.example", "to": ["k7m2xq4h9bwz@smooth-operator.online"], "tls": true },
"auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "spam_score": 0.4 },
"headers": { … }, "body": { … }, "attachments": [ … ], "tables": [ … ],
"fields": {
"total": { "value": 31.5, "confidence": 0.97, "source": "rule", "evidence": "Total: $31.50" },
…
},
"flags": [],
"needs_review": false,
"status": "parsed",
"raw_url": "https://…/v1/messages/msg_01JQ8Z…/raw"
}
| 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 | A convenience copy. Verify against the
t= inside the signature, which is the value actually signed. |
x-mailmint-endpoint | Which endpoint the delivery belongs to, when the mailbox has several. |
x-mailmint-signature | t=<unix>,v1=<hex> |
Verifying the signature #
signed_payload = "<t>" + "." + <the raw request body, byte for byte>
v1 = hex( HMAC_SHA256( key = webhook_secret, message = signed_payload ) )
Three things decide whether a verification is correct: use the raw body (re-serialised JSON will not match), reject stale timestamps (about five minutes — the timestamp is signed, so a captured request cannot be replayed later), and compare in constant time.
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.
app.post('/hooks/mailmint', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('x-mailmint-signature') || '';
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');
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');
}
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${parts.t}.${req.body.toString('utf8')}`)
.digest('hex');
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'));
res.sendStatus(200); // answer fast — deliveries time out at 10 s
if (message.needs_review) console.warn(message.id, 'needs review:', message.flags);
else save(message.fields);
});
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", "")
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")
try:
if abs(time.time() - int(t)) > TOLERANCE_SECONDS:
abort(400, "stale signature")
except ValueError:
abort(400, "malformed timestamp")
body = request.get_data() # the exact bytes — not request.json
expected = hmac.new(SECRET, t.encode() + b"." + body, hashlib.sha256).hexdigest()
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"])
return "", 200 # answer fast — deliveries time out at 10 s
Retries and idempotency #
| Attempt | Delay after the previous failure |
|---|---|
| 1 | immediately |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 1 hour |
| 6 | 6 hours |
- Any
2xxis success. Anything else, or a 10-second timeout, retries — except a4xx, which means your endpoint understood and said no.408and429do retry. - The queue lives in the database, so a redeploy mid-flight does not lose a delivery.
- The body is rebuilt from the stored result at each attempt: if you re-parsed and fixed a field between attempts, the retry carries the corrected version.
- Use
x-mailmint-deliveryas the idempotency key; it does not change between attempts.
Several endpoints on one mailbox #
A single webhook_url is a shared global: two workflows registering on the same mailbox would
overwrite each other. So a mailbox holds a list of endpoints, each with its own id, secret, retry schedule and
health. POST /v1/mailboxes/{id}/webhooks adds one; the secret is returned once, like an API key.
An endpoint whose last 10 deliveries all exhausted six attempts is switched off, with
disabled_at and disabled_reason set, and comes back with
PATCH {"active": true}. Reference.
Testing without mail #
POST /v1/test/deliver injects a message into a mailbox exactly as if it had arrived from the
internet: stored, parsed, given an id, added to the event feed and delivered to your webhook through the real
signing path. Point webhook_url at a local tunnel and iterate on your handler without waiting for
anybody to send you anything.
When you cannot take a webhook #
An n8n on a laptop or a service behind a firewall cannot be reached from the internet. The same events are in a feed you can poll:
curl "$MAILMINT_URL/v1/events?cursor=1421&limit=50" \
-H "Authorization: Bearer $MAILMINT_API_KEY"
The cursor is a strictly increasing integer; next_cursor comes back unchanged when there is
nothing new, and has_more tells you to poll again at once. Events are kept for 7 days.
What this is not #
- Not an email-sending service. MailMint only receives. It sends nothing on your behalf.
- Not only a MIME forwarder. Inbound webhooks from general mail providers typically hand you the message split into headers, text and attachments and leave extraction to you. MailMint adds the cleaned body, tables and the fields of your schema with a confidence each — if you only need the split, a provider you already use may be enough.
- Not an SLA. There is no service level agreement and no published uptime history yet; see the status page.
Free is 300 parsed emails a month, no card. Get an API key, or read the webhook reference.