Blog

/

Email webhook on Cloudflare Workers: HMAC, retries, idempotency

Most email platforms either force you to poll an IMAP mailbox or hand you a webhook contract that quietly drops events on a weekend. Cloudflare Email Routing is neither: inbound mail is delivered to your Worker as a structured event, and the outbound side is your webhook to the rest of the internet. This post is the playbook for the contract — the verification, the retry shape, and the idempotency guarantees that hold up at scale.

Why webhook beats polling for inbound mail

Polling an IMAP mailbox means asking the server “is there anything new” on a schedule. Webhook means the server tells you when there is. The difference is not just latency — it is the difference between a service that reacts to inbound mail and a service that eventually notices it. Cloudflare Email Routing delivers inbound events synchronously into your Worker, so the webhook contract downstream of your Worker is the entire reliability story for your product.

The webhook contract that survives production has three parts: a payload schema that does not break under your hand, an HMAC signature that proves the payload is from you and not from someone else, and a retry policy that matches how your downstream service actually behaves. Each of those is small in isolation; together they are what makes the contract trustworthy.

The Worker that catches the inbound event

The receive-worker contract exposes the inbound message as a structured payload with the to-address, from-address, subject, and a stable message id. The handler normalizes the envelope, persists the message id for idempotency, and forwards the event to the downstream webhook. The interesting line is the order: persist first, forward second. If the downstream call fails, the message id is already in D1 and the retry can pick it up.

export default {
  async email(message, env, ctx) {
    const messageId = message.headers.get("message-id") ?? crypto.randomUUID();
    const from = message.from;
    const to = message.to;
    const subject = message.headers.get("subject") ?? "";

    // Persist first — idempotency key is the SMTP Message-ID
    await env.DB.prepare(
      `INSERT OR IGNORE INTO inbound (id, from_email, to_email, subject, received_at)
       VALUES (?, ?, ?, ?, datetime('now'))`,
    ).bind(messageId, from, to, subject).run();

    // Forward as a webhook with the persisted id
    ctx.waitUntil(postWebhook(env, { id: messageId, from, to, subject }));
  },
};

The ctx.waitUntil is what keeps the Worker alive long enough to complete the webhook delivery without blocking the response to Cloudflare’s caller. The message id is the idempotency key — both sides of the contract can rely on it being unique even across retries.

HMAC signature verification

Every webhook the Worker fires carries an HMAC signature header. The receiving service computes the same HMAC over the raw body using a shared secret and compares it in constant time. The secret never leaves the worker secret store, and the signature is timestamped so a replayed payload from an hour ago is rejected even if the body is intact.

async function postWebhook(env, body) {
  const raw = JSON.stringify(body);
  const ts = Math.floor(Date.now() / 1000).toString();
  const sig = await hmacHex(env.WEBHOOK_SECRET, `${ts}.${raw}`);
  return fetch(env.WEBHOOK_URL, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-flowmails-id": body.id,
      "x-flowmails-ts": ts,
      "x-flowmails-signature": `t=${ts},v1=${sig}`,
    },
    body: raw,
  });
}

async function hmacHex(secret, data) {
  const key = await crypto.subtle.importKey(
    "raw", new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
  );
  const buf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
  return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, "0")).join("");
}

The verifier on the other side reads the timestamp, checks that it is within a 5-minute window, then computes the same HMAC and compares with crypto.timingSafeEqual or its platform equivalent. The timestamp window is what makes the contract safe against replay — a leaked payload stops being valid the moment the window closes.

Retry policy with exponential backoff

The Worker delivers the webhook with a 10-second timeout and a retry schedule of 1s, 5s, 30s, 2m, 10m, 1h, 6h — the seven-attempt pattern most production systems land on after the third incident. Any 5xx response triggers a retry; any 4xx response is a permanent failure (the payload is invalid, retrying will not help). 408 and 429 are retried like 5xx because they are the receiver’s clock, not yours.

The retry is durable across Worker restarts because the message id is already in D1. A Worker that crashes mid-retry does not lose the event — the next invocation finds the row, sees the partial retry count, and resumes the schedule. The scheduler is intentionally simple: row exists and last_attempt_at + delay < now → deliver.

Idempotency keys and duplicate delivery

The SMTP Message-ID is the idempotency key for the entire webhook. Both sides of the contract agree on it: the sender uses it as the x-flowmails-id header, the receiver stores it on the inbound ticket row with a unique constraint. A duplicate delivery does not create a duplicate ticket — the receiver sees the conflict and returns 200 anyway, because the contract considers “already handled” to be a success.

This is the contract pattern that makes the whole thing boring in production. Webhook fires, downstream handler stores the message id, downstream handler does the work, downstream handler returns 200. If any of that repeats, nothing breaks. The receiving service does not need to de-duplicate because the storage layer does it for free.

Common failure modes you find at scale

Clock skew. The 5-minute signature window assumes the receiver’s clock is close to the sender’s. A receiver whose NTP is broken rejects every legitimate webhook and accepts every replay attempt. Test the timestamp handling in your unit suite with a clock set 10 minutes ahead.

Reverse proxies rewriting the body. An nginx in front of your service that adds or strips whitespace invalidates the HMAC. Compute the signature over the raw bytes you actually received, not the bytes the parser produced after normalization.

Retry storms. If a downstream endpoint goes down for an hour and recovers, the queue catches up in seconds — which is the moment the receiver has to be ready for a flood. The rate-limit header on the receiver’s side is part of the contract, not an optional courtesy.

Inbound mail as a webhook that holds up at scale.