PulsePigeonemail infrastructure

Operations

Control plane

API referenceSDK reference
  • PUT /v1/webhooks/status and PATCH /v1/webhooks/status create or update one subscription per tenant.
  • GET /v1/webhooks/status lists the tenant's subscriptions.
  • DELETE /v1/webhooks/status/{subscription_id} removes a subscription.
  • GET /v1/webhooks/status/deliveries returns recent deliveries with attempts, next retry, final status, and dead-letter state.
  • GET /v1/webhooks/status/health summarizes success rate over the last 100 deliveries.
  • POST /v1/webhooks/status/{subscription_id}/test sends a synthetic delivery so you can verify your receiver.
  • POST /v1/webhooks/status/{subscription_id}/rotate-secret issues a fresh signing secret (returned once).
  • POST /v1/webhooks/status/{subscription_id}/deliveries/{delivery_id}/replay re-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 \uXXXX before hashing to match Python's json.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 return false.

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 typeMeaning
acceptedMessage was accepted by PulsePigeon for sending.
queuedMessage was placed on a provider queue.
deliveredRecipient MTA confirmed delivery.
deferredThe provider reported a delay for this recipient. Check the message's delivery timeline to decide whether to resend.
bouncedPermanent delivery failure; recipient was added to suppressions.
complainedRecipient reported the message as spam via their provider feedback loop.
inbound.receivedPulsePigeon received an inbound email at a configured inbound route.
subscription.changedSubscription state changed. scope is stream or global; stream_id identifies the stream when known. Stream events also include project_id.
smtp.submission_errorTenant-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.

AttemptDelay
First retrybase_seconds (default 30)
Second retrybase_seconds × 2 (default 60)
Third retry and laterbase_seconds × 2^(attempts − 1), capped at max_seconds (default 900)
Dead-letterAfter 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. 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. 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. 3. Stop accepting the old secret: After old in-flight deliveries finish, remove the old secret from your verifier.
  4. 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. 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

  1. Reject anything without a well-formed PulsePigeon-Signature header.
  2. Parse the JSON body and pass the parsed object (not the raw bytes) to the verifier.
  3. Verify with the secret you configured on the subscription, then return 2xx only after durable processing.
  4. Deduplicate event_id across the replay window (default 24h) and the retry window.
  5. 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.