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:
| Event | Emitted when |
|---|---|
email.sent | Anypost accepted the message and handed it to the outbound mail system. |
email.delivered | The receiving server accepted the message. |
email.delayed | A delivery attempt failed temporarily and will be retried. |
email.bounced | Delivery failed permanently. |
email.complained | The recipient marked the message as spam. |
email.suppressed | The send was dropped because the address is on your suppression list. |
email.unsubscribed | The recipient used a one-click unsubscribe link. |
email.opened | The recipient opened the message. |
email.clicked | The 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:
| Header | Value |
|---|---|
Anypost-Signature | t=<unix>,v1=<hmac>. One v1= per active signing secret. |
Anypost-Timestamp | The Unix timestamp that is also inside the signature. |
Anypost-Batch-Id | Identifier for this batch. Identical across retries of the same batch. |
User-Agent | Anypost-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:
- Read the
Anypost-SignatureandAnypost-Timestampheaders. - Reject the request if the timestamp is outside a tolerance you choose; a few minutes is typical. This bounds replay of a captured request.
- 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. - 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
2xxresponse is lost in transit, Anypost retries a batch it already delivered. Thebatch_idis stable across retries, and each eventidis unique. De-duplicate on one of them. - Order is not guaranteed. Events can arrive out of the order they
occurred. Sort by
occurred_atif order matters; do not assume, for instance, thatemail.deliveredarrives beforeemail.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
| Operation | Endpoint |
|---|---|
| List webhooks | GET /v1/webhooks |
| Retrieve one | GET /v1/webhooks/{id} |
| Update name, URL, or events | PATCH /v1/webhooks/{id} |
| Pause or resume | PATCH /v1/webhooks/{id} with status |
| Delete | DELETE /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
- Events: the same events, queryable on demand — the pull counterpart to webhooks.
- Send a single email: every send produces the events a webhook delivers.
- Open & click tracking: what
email.openedandemail.clickedcarry. - Suppressions: the list behind
email.suppressed.