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
| Event | When | data |
|---|---|---|
| email.sent | Our mail server accepted the message for delivery (status sent). | from, to, subject, tag, queueId, smtpResponse |
| email.delivered | The recipient's mail server accepted it (status delivered). | from, to, subject, tag, relay, dsn |
| email.bounced | The recipient's server refused it, or delivery gave up (status bounced). | from, to, subject, tag, category, hard, smtpResponse |
| email.complained | The recipient reported it as spam through a feedback loop (status complained). | from, to, subject, tag, source, feedbackType |
| email.delayed | The 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
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
| Field | Type | Description |
|---|---|---|
| X-Postfly-Eventrequired | email.sent | email.delivered | email.bounced | email.complained | email.delayed | email.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-Idrequired | string | The delivery id, signed with the body; the same on every retry. |
| X-Postfly-Signature | string | pattern ^t=[0-9]+,v1=[0-9a-f]{64}$ |
Body
| Field | Type | Description |
|---|---|---|
| eventrequired | email.sent | email.delivered | email.bounced | email.complained | email.delayed | email.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. |
| messageIdrequired | string | null | |
| datarequired | object | Event details. |
| timestamprequired | string (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 — 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.
messageId is the id returned by POST /v1/emails; GET /v1/emails/{id} returns the message's current state.