Own MTA vs Email API: Costs, Control and Vendor Lock-In
Should you run your own MTA or use an email API? A practical comparison of costs, deliverability work, control and vendor lock-in, plus how to stay portable.
6 min read
How to receive email event webhooks safely: verify Standard Webhooks signatures, block replays, handle retries and process events idempotently, with code.
Pidgely teamPublished 4 min readMarkdown version

To receive email event webhooks safely, verify each request's signature over the raw body, reject old timestamps to block replays, respond with a 2xx fast after storing the event, and process events idempotently because retries make delivery at-least-once. With the Standard Webhooks specification, verification means computing an HMAC-SHA256 over webhook-id.webhook-timestamp.body with your secret and comparing it, in constant time, with the v1 value in the webhook-signature header. Skipping any of these steps lets attackers forge events like "bounced" or "complained" that silently suppress real users.
An email platform sends webhooks for events in a message's lifecycle:
email.sent / email.delivered: accepted by the receiving serveremail.deferred: temporary failure, still retryingemail.bounced: permanent failure, with status code and diagnosticemail.complained: the recipient reported spam (via a feedback loop)email.unsubscribed: one-click or link unsubscribeemail.opened / email.clicked: engagement, where tracking is enabledYour application uses these to update user records, stop mailing invalid addresses and trigger alerts. Because several of these events cause irreversible actions, the endpoint is a security boundary.
Standard Webhooks is an open specification for signing and delivering webhooks consistently across providers. A request looks like this:
POST /webhooks/email HTTP/1.1
Host: api.example.com
Content-Type: application/json
webhook-id: msg_2Kx9fQ1aZ7
webhook-timestamp: 1791813802
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{"type":"email.bounced","created_at":"2026-10-12T14:03:22Z","data":{"email_id":"em_8f2a","to":"jane@example.com","bounce":{"type":"hard","status":"5.1.1"}}}webhook-id: unique per message, stable across retries. Use it for deduplication.webhook-timestamp: Unix seconds when the attempt was sent.webhook-signature: one or more space-separated signatures, each prefixed with a version (v1, for HMAC-SHA256). Multiple signatures appear during secret rotation.The secret is usually shown as whsec_ followed by base64. Strip the prefix and base64-decode it to get the HMAC key.
The signed content is the three values joined by periods:
{webhook-id}.{webhook-timestamp}.{raw body}import crypto from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyWebhook(rawBody, headers, secret) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
const sigHeader = headers["webhook-signature"];
if (!id || !ts || !sigHeader) throw new Error("missing headers");
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(ts)) > TOLERANCE_SECONDS) {
throw new Error("timestamp outside tolerance");
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto
.createHmac("sha256", key)
.update(`${id}.${ts}.${rawBody}`)
.digest();
for (const part of sigHeader.split(" ")) {
const [version, sig] = part.split(",");
if (version !== "v1" || !sig) continue;
const given = Buffer.from(sig, "base64");
if (given.length === expected.length && crypto.timingSafeEqual(given, expected)) {
return JSON.parse(rawBody);
}
}
throw new Error("no valid signature");
}import base64, hashlib, hmac, json, time
TOLERANCE = 300
def verify_webhook(raw_body: bytes, headers: dict, secret: str) -> dict:
msg_id = headers["webhook-id"]
ts = headers["webhook-timestamp"]
if abs(time.time() - int(ts)) > TOLERANCE:
raise ValueError("timestamp outside tolerance")
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{ts}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for part in headers["webhook-signature"].split(" "):
version, _, sig = part.partition(",")
if version == "v1" and hmac.compare_digest(sig, expected):
return json.loads(raw_body)
raise ValueError("no valid signature")Official Standard Webhooks libraries exist for many languages; prefer them when available.
Most frameworks parse JSON before your handler runs. Configure the route to give you the unparsed bytes (for example express.raw({ type: "application/json" }) in Express, or request.get_data() in Flask). Verifying a re-serialized object fails whenever whitespace or key order differs.
A valid signed request captured from logs or a proxy would stay valid forever without additional checks. Two layers stop replays:
webhook-id (in Redis with a TTL longer than the tolerance, or in your database) and ignore duplicates.Keep your server clock synchronized with NTP; a drifting clock causes false rejections.
Senders retry when your endpoint times out, refuses the connection or returns a non-2xx status, usually with exponential backoff over hours or days. To work well with retries:
200 or 204. Do the heavy work in a background job.2xx for events you deliberately ignore, such as unknown event types, or they will be retried until the sender gives up.4xx for bad signatures, and alert on a spike, which can indicate a misconfigured secret or an attack.5xx only for real temporary failures, so the retry is meaningful.At-least-once delivery means duplicates. Retries also mean events can arrive out of order: a delivered can arrive after a bounced for a different attempt, or an opened before delivered.
CREATE TABLE webhook_events (
webhook_id text PRIMARY KEY,
type text NOT NULL,
email_id text NOT NULL,
occurred_at timestamptz NOT NULL,
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO webhook_events (webhook_id, type, email_id, occurred_at, payload)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (webhook_id) DO NOTHING;Then design state transitions that tolerate any order: use the event's own created_at, not arrival time, and make terminal states (bounced, complained) win over earlier ones.
Rotate signing secrets periodically and whenever one may have leaked. During rotation, the sender signs with both old and new secrets, producing two v1 entries in the header. Your verifier accepts a request if any signature matches, so you can deploy the new secret without dropping events, then retire the old one.
== for comparison instead of a constant-time function.delivered overwrites a bounced.id.timestamp.body, constant-time compare2xx after durable storage; async processingwebhook-idPidgely signs every event webhook with the Standard Webhooks scheme, retries failed deliveries with backoff, and keeps the webhook-id stable across retries, so the verification code above works without changes.
Concatenate the webhook-id header, the webhook-timestamp header and the raw request body with periods, compute an HMAC-SHA256 with your base64-decoded secret, base64-encode the result and compare it in constant time with each v1 signature in the webhook-signature header.
The signature covers the exact bytes that were sent. Parsing and re-serializing JSON can change whitespace, key order or number formatting, which makes a valid signature fail.
Reject requests whose webhook-timestamp is too far from your current time (a few minutes is typical), and record processed webhook-id values so a captured request cannot be replayed within that window.
Return a 2xx status quickly after verifying and durably storing the event. Do slow work asynchronously. Non-2xx responses and timeouts cause the sender to retry.
Yes, potentially. Webhook delivery is at-least-once, so retries after timeouts or network errors can deliver duplicates. Deduplicate using the webhook-id.
Pidgely team
Published
We build Pidgely, the email API that gets you to the inbox. We write about what we learn delivering email, citing the standards and mailbox provider guidelines behind every claim.
Should you run your own MTA or use an email API? A practical comparison of costs, deliverability work, control and vendor lock-in, plus how to stay portable.
6 min read
How email feedback loops and ARF complaint reports work, how to measure complaint rate correctly, and practical ways to keep it under Gmail's 0.1% target.
5 min read
A practical checklist of the Gmail and Yahoo bulk sender requirements: authentication, DMARC alignment, one-click unsubscribe, spam rate limits and DNS.
5 min read