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 (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:

EventFires when
deliveredThe message reached the recipient’s mailbox provider
bouncedA bounce was received (permanent bounces also suppress the recipient)
complainedThe recipient marked the message as spam
openedThe recipient opened the message (tracking opt-in only)
clickedThe 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.
Terminal window
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).

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

Terminal window
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 — the metadata object 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 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:

StatusMeaning
pendingQueued, not yet attempted (or freshly re-queued by replay)
deliveredYour endpoint returned 2xx
failedAn attempt failed; still within the retry budget
exhaustedHit 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.

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

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