Developer documentation
Webhook verification and delivery
Status webhooks let your service follow a message from acceptance through delivery, deferral, bounce, complaint, inbound mail, subscription changes, and SMTP submission failures. Every delivery is signed; verify the signature before trusting the payload.
PUT /v1/webhooks/statusandPATCH /v1/webhooks/statuscreate or update one subscription per tenant.GET /v1/webhooks/statuslists the tenant's subscriptions.DELETE /v1/webhooks/status/{subscription_id}removes a subscription.GET /v1/webhooks/status/deliveriesreturns recent deliveries with attempts, next retry, final status, and dead-letter state.GET /v1/webhooks/status/healthsummarizes success rate over the last 100 deliveries.POST /v1/webhooks/status/{subscription_id}/testsends a synthetic delivery so you can verify your receiver.POST /v1/webhooks/status/{subscription_id}/rotate-secretissues a fresh signing secret (returned once).POST /v1/webhooks/status/{subscription_id}/deliveries/{delivery_id}/replayre-delivers an event inside the replay window.
Signature header
Every status-webhook POST carries a single PulsePigeon-Signature header with two comma-separated fields: t, the Unix timestamp in seconds when the payload was enqueued, and v1, a hex-encoded HMAC-SHA256 digest. The signed payload is the timestamp, a "dot", and the JSON body serialized with sorted keys and no whitespace.
PulsePigeon-Signature: t=1783380000,v1=<64-char lowercase hex>
signed_payload = "<timestamp>.<canonical-json>"
canonical-json = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)The signer sets the timestamp once, when the delivery is enqueued for dispatch, not when the HTTP request is actually sent. Your tolerance window has to absorb realistic queue delay (scheduler downtime, service restarts, backlog) on top of ordinary clock skew. The official SDK helpers default to 3600 seconds for that reason. Retries retain the original enqueue-time signature, so configure the receiver tolerance to cover your maximum configured delivery delay, including retries, queue delay, and clock skew.
Verify with the TypeScript SDK
import { verifyWebhookSignature } from "@blitzglobaltech/pulsepigeon";
export async function POST(request: Request): Promise<Response> {
const rawBody = await request.text();
const payload = JSON.parse(rawBody); // required: canonical re-serialization
const signatureHeader = request.headers.get("pulsepigeon-signature") ?? "";
const secret = process.env.PULSEPIGEON_WEBHOOK_SECRET!;
if (!verifyWebhookSignature(payload, signatureHeader, secret)) {
return new Response("invalid signature", { status: 400 });
}
// Idempotency: deduplicate by event_id within your replay window.
// Persist before any business effect; return 2xx only after durable storage.
await persistEvent(payload);
return new Response(null, { status: 204 });
}
async function persistEvent(_payload: Record<string, unknown>): Promise<void> {
// Replace with durable, idempotent storage before accepting deliveries.
throw new Error("Configure webhook persistence");
}Verify with the Python SDK
import os
from fastapi import FastAPI, Header, HTTPException, Request, Response
from pulsepigeon_client import verify_webhook_signature
app = FastAPI()
@app.post("/pulsepigeon/webhooks")
async def handle_webhook(
request: Request,
pulsepigeon_signature: str | None = Header(default=None),
) -> Response:
if pulsepigeon_signature is None:
raise HTTPException(status_code=400, detail="missing PulsePigeon-Signature header")
payload = await request.json() # required: canonical re-serialization
secret = os.environ["PULSEPIGEON_WEBHOOK_SECRET"]
if not verify_webhook_signature(payload, pulsepigeon_signature, secret):
raise HTTPException(status_code=400, detail="invalid signature")
# Idempotency: deduplicate by event_id within your replay window.
# Persist before any business effect; return 2xx only after durable storage.
await persist_event(payload)
return Response(status_code=204)
async def persist_event(_payload: dict) -> None:
# Replace with durable, idempotent storage before accepting deliveries.
raise RuntimeError("Configure webhook persistence")Implementation notes
- Always pass the JSON-decoded body to the verifier. The signature covers a canonical re-serialization (sorted keys, no whitespace), not the raw request bytes; comparing the raw bytes silently rejects authentic deliveries.
- Use a constant-time comparison (
crypto.timingSafeEqual,hmac.compare_digest) — the SDK helpers already do this internally. - Non-ASCII payload values must be escaped to
\uXXXXbefore hashing to match Python'sjson.dumps(..., ensure_ascii=True). The TypeScript helper applies this automatically. - The signer emits
t=<digits>,v1=<hex>. The SDK helpers verify the digest and timestamp, but they do not enforce that exact header grammar: Python can accept missing field prefixes and TypeScript can ignore trailing fields. If your receiver requires strict syntax, validate the header separately before calling the helper. Wrong secrets and timestamps outside the configured tolerance returnfalse.
Event types
A subscription declares the event types it wants; empty means all events for the matched projects and recipient domains when a payload has recipient addresses. An accepted event has no recipient or to field, so a recipient domain filter does not restrict accepted events within the tenant. Type names are case-insensitive and de-duplicated on the server.
| Event type | Meaning |
|---|---|
accepted | Message was accepted by PulsePigeon for sending. |
queued | Message was placed on a provider queue. |
delivered | Recipient MTA confirmed delivery. |
deferred | The provider reported a delay for this recipient. Check the message's delivery timeline to decide whether to resend. |
bounced | Permanent delivery failure; recipient was added to suppressions. |
complained | Recipient reported the message as spam via their provider feedback loop. |
inbound.received | PulsePigeon received an inbound email at a configured inbound route. |
subscription.changed | Subscription state changed. scope is stream or global; stream_id identifies the stream when known. Stream events also include project_id. |
smtp.submission_error | Tenant-scoped authenticated SMTP submission failed. |
Retries and dead-letter
A delivery that returns a non-2xx response is retried with an exponential backoff capped at max_seconds. After retry_max_attempts consecutive failures the delivery is marked dead_letter and never retried automatically. These defaults apply to webhook deliveries unless the subscription overrides them.
| Attempt | Delay |
|---|---|
| First retry | base_seconds (default 30) |
| Second retry | base_seconds × 2 (default 60) |
| Third retry and later | base_seconds × 2^(attempts − 1), capped at max_seconds (default 900) |
| Dead-letter | After retry_max_attempts (default 3) consecutive failures |
Tunable per subscription via retry_base_seconds (1-3600, default 30), retry_max_seconds (1-86400, default 900), and retry_max_attempts (1-10, default 3). The original signature is reused on retries. Set the receiver tolerance above the maximum configured delivery delay, including retry backoff, queue delay, and clock skew. A retry after 3600 seconds can fail with the SDK default tolerance even when its signature is valid.
Replay window and idempotency
Every replay request for an existing delivery is accepted for replay_window_seconds (default 86400, one day; maximum 604800, one week) after the delivery was first enqueued. After that window the API returns webhook_replay_window_expired (HTTP 409).
Each event carries a unique event_id; the delivery store also deduplicates by (tenant_id, event_id, type, endpoint_url). Your receiver must still be idempotent: deduplicate event_id within the replay window and the retry window (at minimum retry_max_attempts × max_seconds) before applying the event.
Pruning of finished deliveries is a server-wide setting that is off by default. When it is enabled, finished deliveries are pruned 7 days after their last update. Server-side deduplication by event_id then holds only inside that window, so your receiver must deduplicate on event_id itself. A replay request for a delivery older than 7 days returns webhook_delivery_not_found (HTTP 404).
Secret rotation
Rotation has no pending activation or server-side secret overlap. Deliveries enqueued after rotation use the new secret immediately. Until it reaches the receiver, those deliveries can be rejected and retried; accepting both secrets also covers old in-flight deliveries.
- 1. Plan a retry window: Rotation immediately replaces the signing secret. New deliveries can fail until the new secret is installed on the receiver and then succeed on retry.
- 2. Rotate and install: POST /v1/webhooks/status/{id}/rotate-secret returns the new whsec_... once. Install it on the receiver promptly and accept both secrets.
- 3. Stop accepting the old secret: After old in-flight deliveries finish, remove the old secret from your verifier.
- 4. Optional: rotate the server-side sealing key: Set PULSEPIGEON_WEBHOOK_SECRET_KEY to a new value and move the old one to PULSEPIGEON_WEBHOOK_SECRET_KEY_PREVIOUS so already-sealed secrets keep decrypting.
- 5. Retire the previous key: Once every subscription has been re-sealed under the new key, remove PULSEPIGEON_WEBHOOK_SECRET_KEY_PREVIOUS.
Golden test vectors
The JSON fixture below is generated by the production payload builders and signer. Each vector's signature field is the exact header a subscriber would receive for the corresponding payload signed with the matching secret. Our test suite regenerates the payloads and signatures and tests both SDK helpers. Because these timestamps are fixed, pass tolerance_seconds=None in Python or toleranceSeconds=null in TypeScript when checking the fixture. Keep timestamp validation enabled with a configured delivery tolerance in live receivers. The receiver snippets above are examples and are not executed by the vector tests.
# web/src/app/docs/webhooks/vectors.json
{
"schema_version": "2026-08-14",
"signer_algorithm": "HMAC-SHA256 over <unix-timestamp>.<canonical-json>...",
"vectors": [
{
"name": "delivered-with-unicode-metadata",
"secret": "whsec_docs_golden",
"signature": "t=1786708800,v1=74b316afbe25631b56a5119e78bc65abdc0b9a4f3a9e356f2860120ba967580a",
"payload": { /* …see vectors.json */ }
},
{ "name": "subscription-changed-marketing", "...": "..." },
{ "name": "inbound-received-customer-support", "...": "..." },
{ "name": "bounced-soft-mailbox-full", "...": "..." }
]
}Receiver checklist
- Reject anything without a well-formed
PulsePigeon-Signatureheader. - Parse the JSON body and pass the parsed object (not the raw bytes) to the verifier.
- Verify with the secret you configured on the subscription, then return 2xx only after durable processing.
- Deduplicate
event_idacross the replay window (default 24h) and the retry window. - Keep the old and new secrets accepting during the rotation window, then retire the old one.
A runnable Python receiver that follows this checklist, tested against the golden vectors above, is walked through in Verify signed webhooks and store each event once.