Inbound email webhooks explained
An inbound email webhook is an HTTP POST that a mail service sends to your URL each time an email arrives, carrying the message or the data extracted from it as JSON. A solid receiver verifies the signature, stores the event under its id, answers 2xx quickly and does the slow work in a queue.
How an inbound email becomes an HTTP request
Email is a push protocol between mail servers. Your application speaks HTTP. An inbound email webhook connects the two:
- A sender's mail server delivers a message to an address you own, either through MX records on your domain or through an address the service gives you.
- The service accepts the message, stores the raw MIME and processes it. A plain inbound relay converts it into JSON fields such as from, subject, text, html and attachments. A parsing service goes further and extracts the business fields you defined.
- The service sends a POST with a JSON body to your endpoint, signed with a shared secret.
- Your endpoint answers. A 2xx status means delivered. Anything else, or no answer in time, means the service tries again later.
In ParsedMail the body is an event: {"id": "evt_...", "type": "email.parsed", "created": "...", "data": {...}}, where data is the full email object with the extracted values, per-field confidence and status. The product page on the inbound email webhook shows a complete example.
Verifying the signature before anything else
Your endpoint is a public URL. Anyone who finds it can post to it. The signature proves that a request came from the service and was not changed on the way.
A common scheme, and the one ParsedMail uses, sends a header like this:
ParsedMail-Signature: t=1760433164,v1=5f2c9a...e81
t is a Unix timestamp and v1 is the hex HMAC-SHA256 of "<t>.<raw body>" with your endpoint secret (whsec_...). The rules for checking it:
- Use the raw body. Compute the HMAC over the bytes you received, not over JSON you parsed and re-encoded. Re-encoding changes spacing and key order.
- Compare in constant time. Use
hmac.compare_digestin Python orcrypto.timingSafeEqualin Node. - Check the timestamp. Reject anything older than five minutes. Because
tis part of the signed string, an attacker cannot replay an old request with a fresh timestamp.
import crypto from "node:crypto";
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
When you rotate the secret in the dashboard, deploy the new value before old deliveries stop being retried, or accept both secrets for a short window.
Retries and what they mean for your code
Networks fail and deployments restart servers. A webhook sender that gives up after one attempt loses data. Most senders retry with growing delays. ParsedMail retries any non-2xx answer or timeout on this schedule:
| Attempt | Delay after previous attempt |
|---|---|
| 1 | Immediately after parsing |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 12 hours |
After the sixth attempt the delivery is marked failed. On Growth and Scale an email alert goes out, and on every paid plan you can retry or replay it from the log once your endpoint is fixed.
Retries have one consequence you must design for: your endpoint can receive the same event more than once. If your server processed the event but crashed before answering, or the answer was lost, the sender cannot tell the difference and sends it again.
Idempotency on the receiver side
An idempotent receiver produces the same result whether it sees an event once or five times. The simplest way is a unique key in your database.
Every ParsedMail delivery has an Idempotency-Key header equal to the event id (evt_...), the same on every attempt and every manual retry of that event, and a ParsedMail-Delivery header with the delivery id (dlv_...). The event also carries the email id (eml_...). Which one to use depends on what you want:
- Key on the email id if a replay after a schema change should update the existing record instead of creating a new one.
- Key on the
Idempotency-Keyif every event should be processed exactly once, including manual retries from the log. - Key on the delivery id if you want to log each delivery to each endpoint separately.
CREATE TABLE inbound_events (
email_id TEXT PRIMARY KEY,
received_at TIMESTAMP NOT NULL,
payload JSONB NOT NULL,
processed BOOLEAN NOT NULL DEFAULT FALSE
);
INSERT INTO inbound_events (email_id, received_at, payload)
VALUES ($1, now(), $2)
ON CONFLICT (email_id) DO UPDATE SET payload = EXCLUDED.payload;
The insert is the only thing the HTTP handler needs to do. If it succeeds, answer 204. If the row already existed, also answer 204, because from the sender's point of view the event is delivered.
Deduplication is a different problem from idempotency
Idempotency protects you from the same delivery arriving twice. Deduplication protects you from the same email arriving twice, which happens more often than people expect:
- A supplier sends an invoice and a reminder with the same PDF.
- A colleague forwards a message that a mail rule already forwarded.
- A mail server delivers twice after a timeout on its side.
These are separate emails with separate deliveries, so a delivery key does not catch them. ParsedMail checks each new email against the inbox's last 30 days. An email with the same Message-ID, or the same sender, subject and body, gets status duplicate, is not parsed, is not charged and links to the original through duplicate_of. No webhook is sent for it. For business-level duplicates, such as the same invoice number in two different emails, add a unique check on your side too. The guide to extracting invoice data from email shows one.
Timeouts and queueing
Webhook senders wait a limited time for an answer. ParsedMail waits 15 seconds and does not follow redirects. If your handler creates an order in an ERP, sends a confirmation email and updates a CRM before answering, it will sometimes take longer than that, and the sender will retry a request that is still running.
The fix is to split receiving from processing:
- The HTTP handler verifies the signature, writes the event to a table or a message queue, and answers 204. This takes milliseconds.
- A worker reads unprocessed events, does the slow work and marks each one processed.
- If the worker fails, the event stays unprocessed and is picked up again. The sender is not involved.
def worker_loop():
while True:
row = db.fetch_one(
"SELECT email_id, payload FROM inbound_events "
"WHERE processed = FALSE ORDER BY received_at "
"LIMIT 1 FOR UPDATE SKIP LOCKED"
)
if not row:
time.sleep(1)
continue
email = row.payload["data"]
if email["needs_review"]:
mark_for_attention(row.email_id)
else:
create_order(email["data"])
db.execute("UPDATE inbound_events SET processed = TRUE WHERE email_id = %s", [row.email_id])
FOR UPDATE SKIP LOCKED lets several workers run in parallel without picking the same event.
Monitoring a webhook in production
A receiver that works on day one can fail quietly on day ninety, after a certificate expires or a deployment changes a route. Watch a few signals:
- Delivery failures on the sender side. The delivery log shows every attempt with its status code. On Growth and Scale a failed delivery after all retries sends an email alert.
- Unprocessed rows on your side. Alert when events sit in your table unprocessed for longer than your normal processing time. This catches worker crashes that the sender cannot see, because it already got its 204.
- Review volume. A sudden rise in
needs_reviewemails usually means a sender changed its layout or started attaching scans. It is a data signal, not an outage, but worth knowing about. - Signature failures. Log them with the time and source IP. A burst right after a secret rotation means a server still has the old secret. A slow trickle from unknown addresses means someone is probing the endpoint.
Keep the raw payload for a while after processing. When a value in your system looks wrong, the stored payload tells you whether the parser produced it or your own code changed it later.
Endpoint requirements and testing
A few constraints are common to webhook senders and worth knowing before you deploy. ParsedMail endpoints must use https on a public host, on port 443 or 8443. Private network addresses are refused. Each request has a ParsedMail-Event header (email.parsed or webhook.test) and a User-Agent of ParsedMail-Webhooks/1.0.
To test without waiting for a real email:
- Press the test event button in the dashboard. It sends a
webhook.testevent signed like a real one. - Replay a stored email from the log after changing your handler.
- Return a 500 once on purpose and watch the retry appear in the delivery log.
If you would rather not run an endpoint at all, poll GET /v1/emails?since=... from the email parsing API instead. Webhooks are faster, polling is simpler to firewall. The webhook documentation lists every header and field.
Frequently asked questions
What status code should my webhook return?
Why does my signature check fail on valid requests?
Will a webhook be sent for a duplicate email?
duplicate, is not parsed and does not trigger a delivery.