Webhooks
anymail sends a signed POST request with a JSON body to your URL when an event happens in your workspace. After this page you can receive an event, verify its signature and handle retries.
Two ways to receive events#
| Workspace webhook | Subscriptions | |
|---|---|---|
| Set up in | Settings, Integrations, Webhooks | POST /api/v1/hooks |
| Number of URLs | One for the workspace | Many, each for one event |
| Events | sent, replied, bounced, unsubscribed, clicked |
new_reply, positive_reply, bounce, unsubscribe |
Both use the same delivery format, the same signature and the same retries. The workspace webhook also has a Send test event button that delivers an event of type test.
Events#
The type field of the payload and the X-Seegnals-Event header carry the event name.
| Event | Fires when |
|---|---|
sent |
anymail sent a message. |
replied |
A prospect replied. |
bounced |
A message bounced. The enrolment of the prospect stopped. |
unsubscribed |
A prospect used the one-click unsubscribe link. |
clicked |
A prospect clicked a tracked link. |
new_reply |
A prospect replied. This is the subscription name for replied. |
positive_reply |
A reply was classified as interested. |
bounce |
A message bounced. This is the subscription name for bounced. |
unsubscribe |
A prospect unsubscribed. This is the subscription name for unsubscribed. |
There is no event for an open or for a delivery. Opens are unreliable because Apple Mail Privacy Protection loads the tracking pixel without a person opening the message. SMTP does not report delivery.
Request#
Each delivery is a POST with these headers.
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
Seegnals-Webhook/1 |
X-Seegnals-Event |
The event name. |
X-Seegnals-Delivery |
The id of this delivery. A retry of the same delivery keeps the same id. |
X-Seegnals-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256> |
Payload#
{
"id": "10000000-0000-0000-0000-000000000002",
"type": "replied",
"occurred_at": "2026-09-03T10:00:00Z",
"workspace_id": "00000000-0000-0000-0000-000000000001",
"prospect_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"prospect_email": "ada@example.com",
"campaign_id": "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"campaign_name": "Q3 outbound",
"metadata": {
"replyId": "20000000-0000-0000-0000-000000000001",
"from": "ada@example.com"
}
}
| Field | Notes |
|---|---|
id |
The id of the event. |
type |
The event name. |
occurred_at |
ISO 8601 in UTC with a Z suffix. |
workspace_id |
The workspace. |
prospect_id, prospect_email |
The prospect. |
campaign_id, campaign_name |
The campaign. Both can be null. |
metadata |
Event details. The keys depend on the type. |
Keys you can find in metadata:
| Type | Keys |
|---|---|
sent |
step, messageId |
replied |
replyId, from |
bounced |
step, error |
unsubscribed |
via |
clicked |
url, messageId, via |
positive_reply |
replyId |
The clicked payload never contains the tracking token.
Signing secret#
anymail signs each delivery with the signing secret of the workspace. It has the form whsec_….
- In Settings, Integrations, Webhooks, select Reveal signing secret. Rotate secret creates a new one.
- A workspace that starts with a subscription from the API gets a secret with the first subscription.
- anymail never sends an unsigned delivery. Without a secret, the delivery is rejected.
Verify the signature#
The signature is an HMAC-SHA256 of the string <t>.<raw body>, where t is the timestamp from the header and the body is the exact bytes of the request. The key is the signing secret. The header carries the result as lower-case hex after v1=.
- Read the raw request body. Do not parse it and serialise it again, because that can change key order and number formatting.
- Split the header into
tandv1, compute the HMAC oft + "." + bodyand compare it withv1in constant time. - Reject the request when
tis more than 300 seconds away from your clock. This is your protection against a replayed request, because anymail cannot know that somebody sent a captured request again.
For a longer walkthrough, read Verify a webhook HMAC signature.
Node#
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyAnymailSignature(secret, header, rawBody) {
const parts = Object.fromEntries(
header.split(",").map((part) => part.trim().split("=", 2))
);
const timestamp = Number(parts.t);
const given = parts.v1;
if (!Number.isInteger(timestamp) || !given) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
if (age > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(given.toLowerCase(), "utf8");
return a.length === b.length && timingSafeEqual(a, b);
}
With Express, take the raw body with express.raw({ type: "application/json" }) and pass req.body.toString("utf8") as rawBody.
Python#
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_anymail_signature(secret: str, header: str, raw_body: bytes) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
try:
timestamp = int(parts["t"])
given = parts["v1"].lower()
except (KeyError, ValueError):
return False
if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode("utf-8"),
f"{timestamp}.".encode("utf-8") + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, given)
Delivery and retries#
| Rule | Value |
|---|---|
| Attempts | 3 in total: the first one and two retries. |
| Spacing | About one minute between attempts. |
| Response time | Your endpoint has 10 seconds to answer. |
| Success | Any 2xx status. anymail does not read the response body. |
| Redirects | anymail does not follow a 3xx response. It counts as a failure. Give anymail the final URL. |
| Failure | A non-2xx status, a timeout or a connection error. |
After the third failed attempt, anymail stops. The status of each delivery, the number of tries and the last error are in Settings, Integrations, Webhooks. The event stays recorded in anymail.
Handle deliveries idempotently#
A delivery can arrive more than once, for example when your endpoint processed it but the answer timed out. Store the X-Seegnals-Delivery value and ignore a repeat of an id you have already processed. Answer 2xx for the repeat.
Answer quickly. Accept the request, put the work in a queue and return 200. The 10 second limit covers your whole handler.