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 (and, if you’ve opted in to tracking, when it 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
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) |
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.
Register an endpoint
POST /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://app.pufferpost.com/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…" }}List and delete
GET /v1/webhooks returns the workspace’s endpoints under a data array (same shape as above, including each signing_secret).
curl https://app.pufferpost.com/v1/webhooks \ -H "Authorization: Bearer key_live_…"DELETE /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://app.pufferpost.com/v1/webhooks/whe_01JZ7P9QK3M8XV2YB4N6TD0R5C \ -H "Authorization: Bearer key_live_…"Payload shape
Each delivery is a POST to your endpoint with Content-Type: application/json and this body:
{ "type": "bounced", "message_id": "msg_01JZ7M4F2H9K0WQ8B3V6Y5C7DR", "recipient": "recipient@example.com", "occurred_at": "2026-06-09T10:14:58+00:00", "metadata": { "order_id": "A-10293" }}type— the event type that fired (one of the values above).message_id— the PufferPost message id (msg_…). Always our own id, never the provider’s.recipient— the recipient address the event is about.occurred_at— when the event occurred, ISO-8601 / RFC 3339 (UTC).metadata— themetadataobject you supplied when sending the message (an empty object if you sent none).
Respond with any 2xx status to acknowledge. Any non-2xx, a redirect, or a transport error counts as a failure and triggers a retry.
Verifying the signature
Every request carries an X-Webhook-Signature header. It is an HMAC-SHA256 of the raw request body, keyed by the endpoint’s signing_secret, prefixed with the scheme:
X-Webhook-Signature: sha256=<hex hmac of the raw body>To verify, recompute the HMAC over the exact bytes you received (do not re-serialize the parsed JSON — whitespace differences change the hash) and compare in constant time.
<?php
function verifyPufferPostWebhook(string $rawBody, string $headerValue, string $signingSecret): bool{ $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $signingSecret);
// Constant-time compare to avoid timing leaks. return hash_equals($expected, $headerValue);}
$rawBody = file_get_contents('php://input');$header = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';$secret = 'whsec_3pQ8…'; // the signing_secret for this endpoint
if (!verifyPufferPostWebhook($rawBody, $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 signature does not match. If you rotate by deleting and re-registering an endpoint, the new endpoint gets a new secret.
Retries, backoff, and dead-lettering
Deliveries are queued and retried — 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
GET /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://app.pufferpost.com/v1/webhooks/whe_01JZ7P9QK3M8XV2YB4N6TD0R5C/deliveries \ -H "Authorization: Bearer key_live_…"{ "data": [ { "id": "whd_01JZ7QABCD12EF34GH56JK78MN", "event_type": "bounced", "status": "exhausted", "attempts": 5, "payload": { "type": "bounced", "message_id": "msg_01JZ7M4F2H9K0WQ8B3V6Y5C7DR", "recipient": "recipient@example.com", "occurred_at": "2026-06-09T10:14:58+00:00", "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
POST /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://app.pufferpost.com/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
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.