Webhooks

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=.

  1. Read the raw request body. Do not parse it and serialise it again, because that can change key order and number formatting.
  2. Split the header into t and v1, compute the HMAC of t + "." + body and compare it with v1 in constant time.
  3. Reject the request when t is 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.