Skip to content
ParsedMail

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 secret
  • ParsedMail-Event, either email.parsed or webhook.test
  • ParsedMail-Delivery, a dlv_ id unique per delivery
  • Idempotency-Key, the event id (evt_...), identical on every attempt and every manual retry of the same event
  • User-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 "", 200

If 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:

AttemptDelay after previous
1Immediately after parsing
21 minute
35 minutes
430 minutes
52 hours
612 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_review are 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?

Almost always because the body was parsed and re-serialized before hashing. Hash the raw bytes exactly as received, prefixed with the timestamp and a dot, and use the full whsec_ secret of that endpoint.

What happens if my server is down for a day?

Deliveries retry after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. After that the email is marked failed and stays in the log, where you can retry or replay it once your server is back.

Can one email arrive at my endpoint twice?

Yes, if a response is lost and a retry follows. Every automatic retry and every manual retry of the same event carries the same Idempotency-Key, so deduplicate on it.

Can I send different inboxes to different URLs?

Yes. Endpoints are defined per workspace and can be limited to one inbox, so orders can go to your ERP bridge and leads to your CRM handler.

Is there a test event?

Yes. The test button sends a 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.