Webhooks

Webhooks tell your application what happened to each email: delivered, bounced, opened and so on. Postilon sends an HTTPS POST with a JSON body to your endpoint for every event you subscribe to.

Add an endpoint

Add it in the dashboard under Webhooks, or with the API:

curl https://api.postilon.com/v1/webhooks \
  -H "Authorization: Bearer $POSTILON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://acme.com/webhooks/postilon",
    "subscribed_events": ["email.delivered", "email.bounced", "email.complained"]
  }'

The response includes the endpoint's signing_secret (whsec_…), which you need to verify requests. Leave out subscribed_events to receive every event below. The URL must use HTTPS and be reachable from the internet.

Events

Event When
email.sent Postilon handed the email to its mail server.
email.delivered The recipient's mail server accepted it.
email.bounced The recipient's mail server refused it, or a recipient is on your suppression list (metadata.reason is suppressed).
email.complained The recipient marked it as spam.
email.opened The recipient opened it (open tracking on).
email.clicked The recipient clicked a link (click tracking on).
email.failed It could not be delivered after several attempts.

Payload

{
  "id": "evt_2bC5dE8fG1hJ4kL7mN0p",
  "type": "email.delivered",
  "created_at": "2026-10-25T10:00:03Z",
  "workspace_id": "ws_6qR9sT2uV5wX8yZ1aB4c",
  "data": {
    "email_id": "msg_4kQ9wZ2xT7bLmN3pR8sV",
    "from": "Acme <billing@acme.com>",
    "to": ["jane@example.com"],
    "subject": "Receipt for order #123",
    "recipient": "jane@example.com",
    "status": "delivered",
    "metadata": {},
    "tags": [{ "name": "category", "value": "receipt" }]
  }
}

Each request also carries the headers X-Postilon-Event (the event type) and X-Postilon-Signature.

Verify the signature

X-Postilon-Signature looks like t=1761386403,v1=5f8a…. v1 is the hex HMAC-SHA256 of the timestamp, a period and the raw request body, keyed with your endpoint's signing secret. Compute it yourself and compare in constant time before trusting a request:

import crypto from "node:crypto";

export function verifyPostilonSignature(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const timestamp = Number(parts.t);
  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  const received = Buffer.from(parts.v1 ?? "", "hex");
  return received.length === 32 && crypto.timingSafeEqual(received, Buffer.from(expected, "hex"));
}

Use the body exactly as received, before any JSON parsing. Rejecting old timestamps protects against replayed requests.

Responding and retries

Answer with any 2xx status within 8 seconds; do slow work after responding. If your endpoint fails, times out or cannot be reached, Postilon tries again after 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours and 24 hours: seven attempts over about a day and a half. Each attempt is signed with a fresh timestamp. Until it succeeds a delivery is pending, with next_attempt_at and last_error; after the last attempt it is failed.

Every delivery is listed in the dashboard under Webhooks, where you can send one again; with the API, use GET /v1/webhooks/deliveries and POST /v1/webhooks/deliveries/{id}/replay. A replay is a new delivery, tried at once and then retried like any other. Events can arrive more than once and out of order, so use the event id to ignore duplicates.

To check your endpoint, send a test event (webhook.test) from the dashboard or with POST /v1/webhooks/{id}/test.