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 ended without reaching the recipient: the receiving server refused the address, or the message ran out its retry window.
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 complaint adds a complaint 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.

On an account sending from named IP pools, data also carries ip_pool: which pool the message egressed from. It appears on every event for the message, not just email.sent, so a bounce or complaint rate can be read per pool — the first thing worth knowing when one stream's delivery diverges from another's. Sends that named no pool omit the field.

Any custom X- headers you set on a send are echoed back under data.headers (snake-cased, e.g. X-Campaign-Id becomes x_campaign_id) on the events for that message, including email.suppressed. This lets you attribute an event to your own campaign or list without a lookup.

Bounces

email.bounced adds a bounce object. Its type tells you how the send ended, which is what decides whether the address is worth mailing again:

{
  "id": "evt_018f4f3e7b2c7c808e211a3a4f5b6c7d",
  "type": "email.bounced",
  "occurred_at": "2026-04-30T12:00:01.500000Z",
  "data": {
    "email_id": "email_018f4f3e-7b2c-7c80-8e21-1a3a4f5b6c7d",
    "recipient": "[email protected]",
    "bounce": {
      "type": "expired",
      "classification": null,
      "smtp_code": 551,
      "smtp_response": "551 5.4.7 Next delivery time exceeds the expiry time"
    }
  }
}
typeMeaning
permanentThe receiving server refused the address with a 5xx. Anypost suppresses it.
transientThe receiving server reported a temporary failure on a final delivery report. Not suppressed.
expiredNo verdict from the receiving server at all. The message retried for 72 hours and was dropped from the queue. Not suppressed.

An expired bounce is the terminal event for a message the receiving server never accepted or refused: you see a run of email.delayed, then this. It usually means the receiving side was down, throttling hard, or greylisting for longer than the retry window. Treat it as a failed send, not as a bad address: the recipient is unproven, so retrying them later is reasonable, and Anypost leaves them off your suppression list and out of the hard-bounce rate it measures your sending reputation with. It does still count as an email.bounced event, so split on bounce.type if your own bounce rate is meant to track list quality.

classification is the receiving server's failure category when Anypost can derive one (InvalidRecipient, QuotaIssues, and so on). On an expired bounce there is usually no response to classify, so it is null, and smtp_code and smtp_response may be null too.

Suppressed sends

email.suppressed fires when a send is dropped because the address was already on your suppression list. It adds a suppression object so you can tell why the send was dropped, and whether to treat it like a bounce:

{
  "id": "evt_018f4f3e7b2c7c808e211a3a4f5b6c7d",
  "type": "email.suppressed",
  "occurred_at": "2026-04-30T12:00:01.500000Z",
  "data": {
    "email_id": "email_018f4f3e-7b2c-7c80-8e21-1a3a4f5b6c7d",
    "recipient": "[email protected]",
    "suppression": {
      "reason": "unsubscribed",
      "topic": "newsletter",
      "classification": null
    }
  }
}
reasonMeaning
permanent_bounceThe address previously hard-bounced. Bounce-like.
complaintThe recipient previously marked mail as spam. Bounce-like.
unsubscribedThe recipient opted out. Not a bounce; do not re-add them.
manualYou added the address to the list yourself. Not a bounce.

topic is the suppression scope (* for a global suppression, or the specific topic for a one-click unsubscribe). classification carries the original bounce classification or complaint feedback-type when known, and is null for unsubscribed and manual.

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