Skip to content

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.

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.

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_id is null. 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.
  • group names the subscription topic that was turned off, or is null when 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.

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.
Terminal window
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…"
}
}

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.

Terminal window
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.

Terminal window
curl -X DELETE https://pufferpost.com/api/v1/webhooks/whe_01JZ7P9QK3M8XV2YB4N6TD0R5C \
-H "Authorization: Bearer key_live_…"

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.

Terminal window
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 a 422.
  • events: replaces the subscription list, and needs at least one known event type.
  • active: false pauses delivery to this endpoint without losing its id or secret.

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:

Terminal window
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.

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: true when 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 a clicked is recognised. An opened carries 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 arrive false whether or not anybody was there, so read those as an upper bound. Always false on every other type.

  • automatic_confidence: how firm that judgement is, or null when the event is not automatic. certain means the event itself gave the machine away, including the mail provider’s own verdict on an open; probable means 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 probable as engagement and only skip certain. Being wrong the other way chases someone who did click. If you are counting click rates, skip both.

  • metadata: the metadata object 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.

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.

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: 1767225600
X-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.

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:

Terminal window
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.

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 marked failed and 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 exhausted and 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

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.

Terminal window
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.

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.

Terminal window
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.

Webhook URLs are treated as untrusted destinations:

  • HTTPS only. Registration rejects any non-HTTPS scheme, and localhost, with a 422.
  • 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 3xx can’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.