Docs/Webhooks & events

Webhooks

A webhook is an HTTPS endpoint you register so Anypost can push events to it. Every delivery, bounce, complaint, open, and click is POSTed to your endpoint as it happens, signed so you can verify it came from Anypost.

Register a webhook

POST /v1/webhooks registers an endpoint. Give it a name, an https:// URL, and the events to subscribe to:

curl https://api.anypost.com/v1/webhooks \
  -H "Authorization: Bearer $ANYPOST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Production events",
    "url": "https://hooks.example.com/anypost",
    "events": ["email.delivered", "email.bounced", "email.complained"]
  }'

The response includes the signing_secret, a whsec_... value used to verify deliveries. It is returned only once, at creation. Store it securely; later reads return only its prefix.

{
  "id": "wh_550e8400-e29b-41d4-a716-446655440000",
  "name": "Production events",
  "url": "https://hooks.example.com/anypost",
  "events": ["email.delivered", "email.bounced", "email.complained"],
  "status": "active",
  "signing_secret": "whsec_AbCdEfGhIjKlMnOp..."
}

Event types

A webhook receives only the events it subscribes to. The nine types:

EventEmitted when
email.sentAnypost accepted the message and handed it to the outbound mail system.
email.deliveredThe receiving server accepted the message.
email.delayedA delivery attempt failed temporarily and will be retried.
email.bouncedDelivery failed permanently.
email.complainedThe recipient marked the message as spam.
email.suppressedThe send was dropped because the address is on your suppression list.
email.unsubscribedThe recipient used a one-click unsubscribe link.
email.openedThe recipient opened the message.
email.clickedThe recipient clicked a tracked link.

The delivery request

Anypost POSTs a JSON body that batches one or more events:

{
  "batch_id": "9c1f2a7b8e3d4c5a6f0b1d2e3a4b5c6d...",
  "timestamp": 1730000000,
  "events": [
    {
      "id": "evt_018f4f3e7b2c7c808e211a3a4f5b6c7d",
      "type": "email.delivered",
      "occurred_at": "2026-04-30T12:00:01.500000Z",
      "data": {
        "email_id": "email_018f4f3e-7b2c-7c80-8e21-1a3a4f5b6c7d",
        "recipient": "[email protected]",
        "subject": "Welcome to Acme",
        "topic": "newsletter",
        "tags": ["onboarding"]
      }
    }
  ]
}

Each event carries an id, a type, an occurred_at, and a data object. data always has the email_id; the rest depends on the event. A bounce adds a bounce object, a click adds a tracking object with the destination URL, and an open or click from a mailbox image proxy adds a bot label inside that tracking object (see Open & click tracking). Fields that do not apply to an event are omitted, not sent as null.

The request also carries these headers:

HeaderValue
Anypost-Signaturet=<unix>,v1=<hmac>. One v1= per active signing secret.
Anypost-TimestampThe Unix timestamp that is also inside the signature.
Anypost-Batch-IdIdentifier for this batch. Identical across retries of the same batch.
User-AgentAnypost-Webhooks/1.0.

Verify the signature

Verify every delivery before trusting it. The signature is an HMAC-SHA256 over the timestamp and the raw request body:

  1. Read the Anypost-Signature and Anypost-Timestamp headers.
  2. Reject the request if the timestamp is outside a tolerance you choose; a few minutes is typical. This bounds replay of a captured request.
  3. Compute HMAC-SHA256(signing_secret, "{timestamp}.{raw_body}") and encode it as lowercase hex. Use the exact bytes of the request body, before any JSON parsing.
  4. Compare your result, with a constant-time comparison, against each v1= value in the header. A match on any one is a pass.

The header carries more than one v1= only while a secret rotation is in progress. Checking every component is what lets a delivery keep verifying through a rotation.

The TypeScript, Python, PHP, Ruby, Rust, Go, Java, and .NET SDKs do these four steps for you. The unwrap helper verifies the signature and returns the parsed delivery; it rejects deliveries older than five minutes by default and raises a webhook verification error on any mismatch:

import { unwrapWebhookEvent, WebhookVerificationError } from "anypost";
 
try {
  const delivery = await unwrapWebhookEvent(rawBody, signatureHeader, secret);
  for (const event of delivery.events) {
    // handle event.type, event.data.email_id, ...
  }
} catch (err) {
  if (err instanceof WebhookVerificationError) return reject(400);
  throw err;
}

Pass the raw request body, not a re-serialized object: the signature is over the exact bytes Anypost sent.

When your framework has already parsed the body, reach for the verify-only helper instead. It runs the verify step alone and returns nothing; keep the raw bytes for it, then use your parsed object once it passes:

import { verifyWebhookSignature, WebhookVerificationError } from "anypost";
 
app.post("/anypost", async (req, res) => {
  try {
    await verifyWebhookSignature(req.rawBody, req.header("Anypost-Signature"), secret);
  } catch (err) {
    if (err instanceof WebhookVerificationError) return res.status(400).end();
    throw err;
  }
  for (const event of req.body.events) handle(event); // req.body is already parsed
  res.status(204).end();
});

req.rawBody is not an Express default — capture it with the verify hook on express.json() so the exact bytes survive parsing.

Respond to a delivery

Return a 2xx status as soon as you have stored the batch. Anything else, a 4xx, a 5xx, a redirect, or a connection that does not complete within five seconds, is treated as a failure and retried.

Do the real work after responding, not before. A webhook handler that runs a slow job inline risks the five-second timeout, which turns a successful receipt into a retry.

Retries and delivery guarantees

A failed batch is retried with exponential backoff, from 30 seconds up to one hour between attempts, with jitter. Retries continue for up to 12 hours from the first attempt; a batch still failing after that is dropped.

Two properties to design your handler around:

  • At-least-once. If your 2xx response is lost in transit, Anypost retries a batch it already delivered. The batch_id is stable across retries, and each event id is unique. De-duplicate on one of them.
  • Order is not guaranteed. Events can arrive out of the order they occurred. Sort by occurred_at if order matters; do not assume, for instance, that email.delivered arrives before email.opened.

When an endpoint keeps failing

Anypost tracks the health of each endpoint. After a run of consecutive failures it pauses delivery to that endpoint — the webhook's status reads circuit_disabled — and retries on a slow probe schedule rather than hammering a URL that is down. Events are still queued during this window, so a brief outage loses nothing. If the outage is sustained, the status becomes disabled automatically.

A disabled webhook, whether you disabled it or Anypost did, receives no deliveries, and events that occur while it is disabled are not queued for it. To recover that gap, page your event history with GET /v1/events once the endpoint is healthy and you have re-enabled the webhook.

Rotate the signing secret

POST /v1/webhooks/{id}/rotate-secret issues a new signing secret and returns it once.

The previous secret stays valid for a 24-hour grace window. During the window every delivery is signed with both secrets, each as its own v1= component, so an endpoint still holding the old secret keeps verifying while you redeploy with the new one. Rotating again before the window ends returns 409.

Test a webhook

POST /v1/webhooks/{id}/test sends a single webhook.test event to the endpoint right away and reports the outcome, the status code, the latency, and any error:

curl -X POST https://api.anypost.com/v1/webhooks/wh_550e8400.../test \
  -H "Authorization: Bearer $ANYPOST_API_KEY"

The test is one-shot: it is not retried and does not appear in delivery history. It works on a disabled webhook too, so you can confirm an endpoint is reachable before re-enabling it. webhook.test is reserved for this endpoint and is never emitted by real mail.

Manage webhooks

OperationEndpoint
List webhooksGET /v1/webhooks
Retrieve oneGET /v1/webhooks/{id}
Update name, URL, or eventsPATCH /v1/webhooks/{id}
Pause or resumePATCH /v1/webhooks/{id} with status
DeleteDELETE /v1/webhooks/{id}

To pause delivery without losing the configuration, PATCH the webhook with status set to disabled; set it back to active to resume. Updating the events list changes what the webhook receives from the next event onward.

Where to go next