Webhooks

Webhooks

Delivery events postfly POSTs to your endpoint, how to verify their signature, deduplicate by Webhook-Id and handle retries.

Webhooks are set up in the dashboard under Webhooks: a URL, the events to receive, and a signing secret (whsec_…, shown once). postfly then POSTs one JSON body per event to the URL.

Events

EventWhendata
email.sentOur mail server accepted the message for delivery (status sent).from, to, subject, tag, queueId, smtpResponse
email.deliveredThe recipient's mail server accepted it (status delivered).from, to, subject, tag, relay, dsn
email.bouncedThe recipient's server refused it, or delivery gave up (status bounced).from, to, subject, tag, category, hard, smtpResponse
email.complainedThe recipient reported it as spam through a feedback loop (status complained).from, to, subject, tag, source, feedbackType
email.delayedThe recipient's server asked us to try again later (a deferral); delivery is retried. At most once an hour per message, never after it is delivered or bounced; the status stays sent.from, to, subject, tag, relay, dsn, smtpResponse

Inbound mail (replies as email.received) is not available.

The request

request
POST <your webhook URL>
Content-Type: application/json
User-Agent: postfly-webhooks/1.0
X-Postfly-Event: email.delivered
Webhook-Id: wha_3kTq9ZbW2xYv
X-Postfly-Signature: t=1757584804,v1=5f0c…

{
  "event": "email.delivered",
  "messageId": "m_3kTq9ZbW2xYv",
  "data": { "to": "[email protected]", … },
  "timestamp": "2026-09-11T10:00:04.000Z"
}

Headers

FieldTypeDescription
X-Postfly-Eventrequiredemail.sent | email.delivered | email.bounced | email.complained | email.delayedemail.delayed — the recipient's server deferred the message and delivery is retried; sent at most once an hour per message, never after email.delivered or email.bounced.
Webhook-IdrequiredstringThe delivery id, signed with the body; the same on every retry.
X-Postfly-Signaturestringpattern ^t=[0-9]+,v1=[0-9a-f]{64}$

Body

FieldTypeDescription
eventrequiredemail.sent | email.delivered | email.bounced | email.complained | email.delayedemail.delayed — the recipient's server deferred the message and delivery is retried; sent at most once an hour per message, never after email.delivered or email.bounced.
messageIdrequiredstring | null
datarequiredobjectEvent details.
timestamprequiredstring (date-time)

Verifying the signature

X-Postfly-Signature is t=<unix seconds>,v1=<hex>, where v1 is an HMAC-SHA256 of "<t>.<Webhook-Id>.<raw body>" keyed with your signing secret (the whole whsec_… string). Compute it over the raw request body, before any JSON parsing; compare in constant time; reject a t more than 5 minutes off.

Node
// Node — e.g. an Express route with express.raw({ type: 'application/json' })
import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyPostfly(rawBody, id, header, secret) {
  const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? '');
  if (!m || !id) return false;
  const [, t, v1] = m;
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${id}.${rawBody}`).digest('hex');
  return timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(expected, 'hex'));
}

app.post('/hooks/postfly', express.raw({ type: 'application/json' }), (req, res) => {
  const id = req.get('webhook-id');
  const ok = verifyPostfly(
    req.body.toString('utf8'),
    id,
    req.get('x-postfly-signature'),
    process.env.POSTFLY_WEBHOOK_SECRET,
  );
  if (!ok) return res.sendStatus(400);
  // alreadyProcessed() and handle() are yours: remember ids for at least 5 minutes.
  if (alreadyProcessed(id)) return res.sendStatus(200); // a retry or a replay
  handle(JSON.parse(req.body));
  res.sendStatus(200);
});

Duplicates & replays

Webhook-Id identifies the delivery and stays the same when it is retried or resent from the dashboard, so the same event can arrive more than once — process each id once. The id is signed together with the body and t, so remembering the ids you accepted in the last 5 minutes (the timestamp tolerance) is enough to turn away a captured request played back to you.

Retries

Answer with any 2xx to accept. A 5xx, 408, 429, a network error or no answer within 10 s is retried: 3 attempts in total, 10 s and then 100 s apart. Any other 4xx is final. The dashboard's Webhooks page lists recent delivery attempts.

Rotating the secret

Rotate secret on the webhook shows a new whsec_… once; the old one stops working immediately. Webhooks created before postfly stored secrets are marked unsigned — rotate secret and are delivered without X-Postfly-Signature until you rotate. A stored secret that can't be decrypted is never downgraded to unsigned: the delivery isn't sent and shows as dead until you rotate.

The event's messageId is the id returned by POST /v1/emails; GET /v1/emails/{id} returns the message's current state.