Webhooks
Webhooks push email activity to your own HTTPS endpoint as it happens, so you don’t have to poll. When a message is delivered, bounces, or draws a complaint, when a recipient unsubscribes, and (if you’ve opted in to tracking) when a message is opened or clicked, PufferPost fans the event out to every endpoint in the workspace that subscribes to it.
Every request is signed with a per-endpoint secret so you can verify it came from us, and every attempt is persisted so it can be retried and replayed.
All webhook routes require an API key with the matching scope: webhooks:read for the list/inspect endpoints, webhooks:manage for register/delete/replay. See Authentication for scopes.
Event types
Section titled “Event types”An endpoint subscribes to a subset of these event types:
| Event | Fires when |
|---|---|
delivered |
The message reached the recipient’s mailbox provider |
bounced |
A bounce was received (permanent bounces also suppress the recipient) |
complained |
The recipient marked the message as spam |
opened |
The recipient opened the message (tracking opt-in only) |
clicked |
The recipient clicked a tracked link (tracking opt-in only) |
unsubscribed |
The recipient opted out through the one-click unsubscribe link |
opened and clicked are only emitted for workspaces that have enabled open/click tracking. You may subscribe to them regardless, but no deliveries are produced until tracking is on.
unsubscribed
Section titled “unsubscribed”PufferPost hosts the one-click unsubscribe link, so an opt-out happens on our side and this event is how you hear about it. It carries two fields the others do not treat the same way:
message_idisnull. The unsubscribe link is signed against the workspace, the recipient and the topic, never a message, and a recipient may follow a link in an email of any age.groupnames the subscription topic that was turned off, or isnullwhen the recipient opted out of everything from the workspace. This field appears only on this event.
A workspace-wide opt-out also adds the recipient to your suppression list, so you can reconcile it from GET /api/v1/suppressions. A topic opt-out does not: for those, this event is the only signal.
Register an endpoint
Section titled “Register an endpoint”POST /api/v1/webhooks registers an endpoint. The body is a JSON object with two fields:
url: required, must be a public HTTPS URL (see SSRF hardening).events: required, a non-empty list of event-type values from the table above.
curl -X POST https://pufferpost.com/api/v1/webhooks \ -H "Authorization: Bearer key_live_…" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.example.com/pufferpost", "events": ["delivered", "bounced", "complained"] }'A 201 Created returns the endpoint, including its signing secret. The secret is shown so you can store it; keep it safe. It is what authenticates every payload we send you.
{ "id": "whe_01JZ7P9QK3M8XV2YB4N6TD0R5C", "url": "https://hooks.example.com/pufferpost", "events": ["delivered", "bounced", "complained"], "active": true, "signing_secret": "whsec_3pQ8…", "created_at": "2026-06-09T10:15:00+00:00"}If url is not HTTPS (or points at localhost), the request is rejected with 422 Unprocessable Entity and a stable error envelope:
{ "error": { "code": "unprocessable_entity", "message": "Webhook URL must be a public HTTPS endpoint.", "request_id": "req_01JZ7P…" }}Read, list and delete
Section titled “Read, list and delete”GET /api/v1/webhooks returns the workspace’s endpoints under a data array (same shape as above, including each signing_secret). GET /api/v1/webhooks/{id} returns one.
curl https://pufferpost.com/api/v1/webhooks \ -H "Authorization: Bearer key_live_…"DELETE /api/v1/webhooks/{id} removes an endpoint and returns 204 No Content. An id outside your workspace is a 404, never a cross-tenant delete.
curl -X DELETE https://pufferpost.com/api/v1/webhooks/whe_01JZ7P9QK3M8XV2YB4N6TD0R5C \ -H "Authorization: Bearer key_live_…"Change an endpoint
Section titled “Change an endpoint”PATCH /api/v1/webhooks/{id} updates the target URL, the subscribed events, or whether the endpoint is active, without delete-and-recreate. Every field is optional; the ones you omit are left alone, and the signing secret is untouched.
curl -X PATCH https://pufferpost.com/api/v1/webhooks/whe_01JZ7P9QK3M8XV2YB4N6TD0R5C \ -H "Authorization: Bearer key_live_…" \ -H "Content-Type: application/json" \ -d '{ "events": ["delivered", "bounced"], "active": false }'url: must be a public HTTPS endpoint, same rule as registration. Anything else is a422.events: replaces the subscription list, and needs at least one known event type.active:falsepauses delivery to this endpoint without losing its id or secret.
Send a test event
Section titled “Send a test event”POST /api/v1/webhooks/{id}/test delivers one signed event to the endpoint right now, so you can confirm a receiver works before real traffic depends on it. The body picks the event type to simulate and defaults to delivered:
curl -X POST https://pufferpost.com/api/v1/webhooks/whe_01JZ7P9QK3M8XV2YB4N6TD0R5C/test \ -H "Authorization: Bearer key_live_…" \ -H "Content-Type: application/json" \ -d '{ "event": "bounced" }'It returns 201 with the resulting delivery — status, attempts, last_attempt_at and the payload that was sent — so a failing receiver shows up here rather than in a support thread. Test deliveries appear in the delivery list like any other.
Payload shape
Section titled “Payload shape”Each delivery is a POST to your endpoint with Content-Type: application/json and this body:
{ "id": "evt_01JZ7QABCD12EF34GH56JK78MN", "api_version": "v1", "type": "bounced", "message_id": "msg_01JZ7M4F2H9K0WQ8B3V6Y5C7DR", "recipient": "recipient@example.com", "occurred_at": "2026-06-09T10:14:58+00:00", "automatic": false, "automatic_confidence": null, "metadata": { "order_id": "A-10293" }}-
id: the event id (evt_…), unique to this activity. Use it to deduplicate (see below). -
api_version: the payload contract this body follows. A new event type or a changed field ships under a new version, so you can branch on it rather than guess. -
type: the event type that fired (one of the values above). -
message_id: the PufferPost message id (msg_…), the same id the send API returned. -
recipient: the recipient address the event is about. -
occurred_at: when the event occurred, ISO-8601 / RFC 3339 (UTC). -
automatic:truewhen a machine produced the engagement rather than a person. Mail-security gateways follow every link in a message to scan it, and some do it from ranges that serve a mail provider rather than one person, which is how aclickedis recognised. Anopenedcarries it only when the mail provider that fetched the tracking image reported that fetch as a bot; nothing else about an open is judged, because an inbox pre-loading images looks the same as somebody reading. Most opens therefore arrivefalsewhether or not anybody was there, so read those as an upper bound. Alwaysfalseon every other type. -
automatic_confidence: how firm that judgement is, ornullwhen the event is not automatic.certainmeans the event itself gave the machine away, including the mail provider’s own verdict on an open;probablemeans only the network it came from did, which only ever applies to a click.certain— the event itself gave the machine away: its user agent, the provider’s own verdict, or arriving too soon after the send to have been anybody.probable— it was judged by the network it came from. A shared office line reading mail for several colleagues satisfies that rule as readily as a mail provider does.
If your automation chases recipients who did not click, treat
probableas engagement and only skipcertain. Being wrong the other way chases someone who did click. If you are counting click rates, skip both. -
metadata: themetadataobject you supplied when sending the message (an empty object if you sent none).
A webhook carries what happened, not the device it happened on: the IP address, user agent and followed link recorded for an open or a click are shown on the message timeline in the dashboard and are not part of this payload.
Respond with any 2xx status to acknowledge. Any non-2xx, a redirect, or a transport error counts as a failure and triggers a retry.
Deduplicating
Section titled “Deduplicating”Delivery is at-least-once: a receiver that is slow to answer, or answers with an error and then succeeds on a retry, can see the same event twice. Every payload therefore carries an id of its own, stable across retries and replays of that event.
Record the ids you have processed and ignore an id you have seen before. Do not dedupe on message_id: one message produces many events, and an opened or clicked message produces one per occurrence, so they share the message id by design.
Verifying the signature
Section titled “Verifying the signature”Every request carries two headers: an X-Webhook-Timestamp (Unix seconds, when we signed it) and an X-Webhook-Signature. The signature is an HMAC-SHA256 over <timestamp>.<raw body>, keyed by the endpoint’s signing_secret, prefixed with the scheme:
X-Webhook-Timestamp: 1767225600X-Webhook-Signature: sha256=<hex hmac of "<timestamp>.<raw body>">The signature header can carry more than one value, comma-separated, and it does for 24 hours after a secret rotation — one signature per secret that is currently valid:
X-Webhook-Signature: sha256=<hmac with the new secret>,sha256=<hmac with the previous one>So treat the header as a list and accept the delivery when any value matches. A receiver that compares the whole header against a single expected signature works today and then rejects every delivery the moment you rotate.
To verify: first check the timestamp is recent (we recommend rejecting anything more than a few minutes old, which stops a captured request from being replayed later), then recompute the HMAC over timestamp + "." + rawBody and compare in constant time against each value in the header. Sign over the exact bytes you received. Do not re-serialize the parsed JSON, since whitespace differences change the hash.
<?php
function verifyPufferPostWebhook(string $rawBody, string $timestamp, string $headerValue, string $signingSecret): bool{ // Reject stale deliveries (replay protection): timestamp must be within 5 minutes. if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) { return false; }
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $signingSecret);
// The header holds one signature per currently valid secret (two during a rotation window), // so any match is a valid delivery. Constant-time compare to avoid timing leaks. foreach (explode(',', $headerValue) as $candidate) { if (hash_equals($expected, trim($candidate))) { return true; } }
return false;}
$rawBody = file_get_contents('php://input');$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';$header = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';$secret = 'whsec_3pQ8…'; // the signing_secret for this endpoint
if (!verifyPufferPostWebhook($rawBody, $timestamp, $header, $secret)) { http_response_code(400); exit;}
$event = json_decode($rawBody, true);// … handle $event['type'], $event['message_id'], etc.http_response_code(200);Reject any request whose timestamp is stale or whose signature does not match.
Rotating the signing secret
Section titled “Rotating the signing secret”POST /api/v1/webhooks/{id}/rotate-secret mints a new secret and returns the endpoint with it. You do not have to delete and re-register:
curl -X POST https://pufferpost.com/api/v1/webhooks/whe_01JZ7P9QK3M8XV2YB4N6TD0R5C/rotate-secret \ -H "Authorization: Bearer key_live_…"The previous secret stays valid for 24 hours, and deliveries in that window are signed with both, so a receiver can be updated without dropping events. Roll it like this: rotate, deploy the new secret, and let the old one expire.
Retries, backoff, and dead-lettering
Section titled “Retries, backoff, and dead-lettering”Deliveries are queued and retried, so you don’t lose events to a momentary outage.
- A failed attempt (non-
2xx, redirect, timeout, or connection error) is markedfailedand retried. - Retries wait for a backoff window of 1 minute since the last attempt before the next try.
- After 5 failed attempts the delivery is dead-lettered: its status becomes
exhaustedand it is no longer retried automatically. Recover it with replay.
Delivery statuses you’ll see when inspecting attempts:
| Status | Meaning |
|---|---|
pending |
Queued, not yet attempted (or freshly re-queued by replay) |
delivered |
Your endpoint returned 2xx |
failed |
An attempt failed; still within the retry budget |
exhausted |
Hit the 5-attempt cap; dead-lettered, awaiting manual replay |
Inspecting delivery attempts
Section titled “Inspecting delivery attempts”GET /api/v1/webhooks/{id}/deliveries lists the recent attempts for one endpoint, newest first, under a data array. The endpoint id is workspace-scoped; a foreign id is a 404.
curl https://pufferpost.com/api/v1/webhooks/whe_01JZ7P9QK3M8XV2YB4N6TD0R5C/deliveries \ -H "Authorization: Bearer key_live_…"{ "data": [ { "id": "whd_01JZ7QABCD12EF34GH56JK78MN", "event_type": "bounced", "status": "exhausted", "attempts": 5, "payload": { "id": "evt_01JZ7QABCD12EF34GH56JK78MN", "api_version": "v1", "type": "bounced", "message_id": "msg_01JZ7M4F2H9K0WQ8B3V6Y5C7DR", "recipient": "recipient@example.com", "occurred_at": "2026-06-09T10:14:58+00:00", "automatic": false, "automatic_confidence": null, "automatic_confidence": null, "metadata": { "order_id": "A-10293" } }, "last_attempt_at": "2026-06-09T10:19:58+00:00", "created_at": "2026-06-09T10:14:58+00:00" } ]}Each record shows the event_type, current status, the attempts count, the exact payload that was (or will be) POSTed, and timestamps. The delivery id (whd_…) is what you replay.
Replaying a delivery
Section titled “Replaying a delivery”POST /api/v1/webhooks/deliveries/{id}/replay re-queues a single delivery for a fresh round of attempts. Use it after fixing a downstream outage or to recover an exhausted delivery. It resets the attempt count and sets the status back to pending; the worker picks it up. The body is an empty JSON object.
curl -X POST https://pufferpost.com/api/v1/webhooks/deliveries/whd_01JZ7QABCD12EF34GH56JK78MN/replay \ -H "Authorization: Bearer key_live_…" \ -H "Content-Type: application/json" \ -d '{}'Returns 202 Accepted once re-queued. A delivery id outside your workspace is a 404.
SSRF hardening and HTTPS-only
Section titled “SSRF hardening and HTTPS-only”Webhook URLs are treated as untrusted destinations:
- HTTPS only. Registration rejects any non-HTTPS scheme, and
localhost, with a422. - Public hosts only, checked at send time. Before each delivery the host is resolved and every address it maps to must be a public IP. Private, loopback, and reserved ranges are refused. This closes DNS-rebinding and internal-hostname bypasses, even if the host resolved differently at registration.
- No redirects. The delivery client follows zero redirects, so a
3xxcan’t bounce a public URL to an internal one.
A URL that fails the public-IP check at delivery time simply fails that attempt (and is retried under the normal backoff), rather than reaching an internal service.
See the full endpoint catalogue in the API reference.