Inbound email webhook
An inbound email webhook is an HTTPS request your server receives whenever an email arrives at an address. ParsedMail sends one signed POST per email with the extracted fields already typed as JSON, retries failed deliveries five times over roughly 15 hours, and gives each delivery an idempotency key.
Email to webhook, without running a mail server
The classic way to turn email into an HTTP call is to run your own MX host, accept SMTP, decode MIME and then post the result to your app. An inbound mail provider can take over the SMTP part, but you still receive a raw message and have to find the order number or the lead's phone yourself.
ParsedMail does both halves. Mail reaches an inbox address such as [email protected], the body and attachments are read against your schema, and your endpoint receives the finished fields. Nothing to host and no templates per sender. If you want to see how the extraction itself works, read about the AI email parser.
What each request contains
Every delivery is a POST with a JSON body and these headers:
ParsedMail-Signature: t=1760512345,v1=5f2c..., the HMAC-SHA256 of"<t>.<raw body>"with your endpoint secretParsedMail-Event, eitheremail.parsedorwebhook.testParsedMail-Delivery, adlv_id unique per deliveryIdempotency-Key, the event id (evt_...), identical on every attempt and every manual retry of the same eventUser-Agent: ParsedMail-Webhooks/1.0
The body wraps the email object:
{
"id": "evt_2Lw8",
"type": "email.parsed",
"created": "2026-10-15T09:41:07Z",
"data": {
"id": "eml_9Tq1",
"object": "email",
"inbox": "inb_4Hc0",
"inbox_address": "[email protected]",
"schema": "sch_7Pn2",
"schema_version": 3,
"status": "parsed",
"from": {"email": "[email protected]", "name": "Listings"},
"subject": "New inquiry for 14 Elm Street",
"data": {"name": "Marcus Hale", "phone": "+1 555 0142", "budget": {"amount": 420000, "currency": "USD"}},
"confidence": {"name": 0.98, "phone": 0.96, "budget": 0.88},
"confidence_avg": 0.94,
"needs_review": false,
"warnings": [],
"sandbox": false
}
}The data object is identical to what GET /emails/{id} returns in the email parsing API, so one handler can serve both paths.
Verify the signature in Node
Verify against the raw request body, before any JSON parsing changes whitespace. Reject timestamps older than five minutes to stop replayed requests, and compare in constant time:
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.PARSEDMAIL_WHSEC; // whsec_...
app.post("/hooks/parsedmail", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("ParsedMail-Signature") || "";
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 res.sendStatus(400);
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${t}.${req.body.toString("utf8")}`)
.digest("hex");
const given = Buffer.from(parts.v1 || "", "hex");
const ok = given.length === 32 &&
crypto.timingSafeEqual(given, Buffer.from(expected, "hex"));
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body);
queue.add(event, { jobId: req.get("Idempotency-Key") });
res.sendStatus(200);
});Verify the signature in Python
The same check with Flask. request.get_data() returns the body exactly as sent:
import hmac, hashlib, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["PARSEDMAIL_WHSEC"].encode()
@app.post("/hooks/parsedmail")
def parsedmail_hook():
header = request.headers.get("ParsedMail-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t = int(parts.get("t", "0"))
if abs(time.time() - t) > 300:
abort(400)
body = request.get_data()
expected = hmac.new(SECRET, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
abort(401)
enqueue(request.get_json(), key=request.headers["Idempotency-Key"])
return "", 200If you rotate the secret in the dashboard, deploy the new value before you switch, and keep the handler short: the request times out after 15 seconds.
Retries, failures and idempotency
A 2xx response is a success. Any other status, a timeout after 15 seconds or a redirect (redirects are not followed) counts as a failure and the delivery is retried on this schedule:
| Attempt | Delay after previous |
|---|---|
| 1 | Immediately after parsing |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 12 hours |
After the sixth attempt the email is marked failed, and on Growth and Scale you get an email alert. Because retries can reach you more than once, for instance when your server processed the event but the response was lost, store the Idempotency-Key with a unique constraint and ignore repeats. Once the endpoint is healthy again, retry or replay individual deliveries from the log. The webhook documentation covers each field.
Endpoint requirements
- HTTPS only, on a public host, port 443 or 8443
- Endpoints per workspace, optionally scoped to a single inbox
- Webhooks and retries are included in every paid plan, from Starter up
- Emails waiting in
needs_revieware delivered after a reviewer approves them, with the corrected values
No code on the receiving side? Point the built-in webhook trigger of Zapier, Make or n8n at the endpoint URL and map the fields there.
Processing events safely on your side
A webhook handler should do three things and nothing more: verify, store, acknowledge. Write the event to a table or queue keyed by Idempotency-Key, return 200, and let a worker push the data on to your ERP, CRM or spreadsheet. If the worker fails, you still have the event and can retry locally without asking for a redelivery.
Check needs_review and confidence_avg in the worker if you want your own rules on top of the review queue, and use schema_version when consumer code depends on a specific field set. To process mail that was parsed before your endpoint existed, page through GET /emails with since once, then rely on the webhook from there.
Frequently asked questions
Why does my signature check fail?
whsec_ secret of that endpoint.
What happens if my server is down for a day?
Can one email arrive at my endpoint twice?
Idempotency-Key, so deduplicate on it.
Can I send different inboxes to different URLs?
Is there a test event?
webhook.test event signed like a real one, so you can check verification before live mail arrives.Related
See your own email as JSON
Paste one into the demo, describe the fields and look at the result. No account needed.