What is a webhook, and how do you verify an HMAC signature?
A webhook is an HTTP request that one system sends to your URL when something happens. You do not poll for changes. The system calls you, with a small JSON body that says what happened. That makes a webhook useful for pushing a reply into a CRM or a chat channel within seconds. It also means anyone who guesses your URL can post to it, which is why the signature exists.
What does anymail send, and when?
anymail sends one signed POST to one URL per workspace. The events are sent, replied, bounced, unsubscribed and clicked. There is no opened event, because an open can come from a privacy proxy and not from a person.
The body is JSON with these fields: id, type, occurred_at, workspace_id, prospect_id, prospect_email, campaign_id, campaign_name and metadata. The time is ISO 8601 in UTC.
Every request carries three custom headers:
| Header | Content |
|---|---|
X-Seegnals-Event |
The event type, such as replied |
X-Seegnals-Delivery |
The identifier of this delivery |
X-Seegnals-Signature |
t=<unix seconds>,v1=<hex> |
The header names keep the old product prefix. They are identifiers in the code and they are what your receiver reads.
How is the signature built?
The scheme is the one Stripe popularised, so most developers have seen it. The signed string is the timestamp, a dot and the raw body:
signed_string = <t> + "." + <raw request body>
v1 = hex( HMAC_SHA256(signing_secret, signed_string) )
Two details cause most failures:
- The raw body. anymail serialises the payload once and signs those exact bytes. If your framework parses the JSON and you serialise it again, key order or number formatting can change and the hash will not match. Read the bytes before any parsing.
- The timestamp is inside the signature. An attacker who captured a request cannot replay it next month, because your check rejects anything outside a 300 second window. This is your only defence against replay, because the sender has no way to know about a repeat.
Compare with a constant-time function. A plain == leaks how many leading characters were right.
How do you verify it in Node?
This uses Express. The route receives the body as a Buffer, so the bytes stay untouched.
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = "YOUR_SIGNING_SECRET";
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((part) => part.trim().split("=", 2))
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!Number.isInteger(t) || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(v1.toLowerCase());
return a.length === b.length && timingSafeEqual(a, b);
}
const app = express();
app.post("/hooks/anymail", express.raw({ type: "application/json" }), (req, res) => {
const body = req.body.toString("utf8");
if (!verify(body, req.get("X-Seegnals-Signature") ?? "", SECRET)) {
return res.sendStatus(400);
}
const deliveryId = req.get("X-Seegnals-Delivery");
// Skip if deliveryId was seen before, then handle JSON.parse(body).
res.sendStatus(200);
});
app.listen(3000);
How do you verify it in Python?
This uses Flask. request.get_data() returns the bytes as they arrived.
import hashlib
import hmac
import time
from flask import Flask, request
SECRET = "YOUR_SIGNING_SECRET"
TOLERANCE_SECONDS = 300
app = Flask(__name__)
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts["t"])
v1 = parts["v1"].lower()
except (KeyError, ValueError):
return False
if abs(time.time() - t) > TOLERANCE_SECONDS:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/hooks/anymail")
def hook():
if not verify(request.get_data(), request.headers.get("X-Seegnals-Signature", ""), SECRET):
return "", 400
delivery_id = request.headers.get("X-Seegnals-Delivery")
# Skip if delivery_id was seen before, then handle request.get_json().
return "", 200
Keep the signing secret in an environment variable or a secrets manager. The placeholder above is for reading only.
What happens when your endpoint fails?
A delivery that does not receive a 2xx response is tried again. The rules are short:
- A delivery gets 3 attempts in total: the first and two retries.
- Retries run about a minute apart.
- Each attempt waits at most 10 seconds for your response.
- A redirect counts as a failure. Point the webhook at the final URL.
- After the third failure the delivery is marked failed and is not sent again.
Each attempt is signed with a fresh timestamp, so a retry inside the 300 second window passes your check. The X-Seegnals-Delivery value stays the same across attempts. Store it, and acknowledge a repeat without processing it twice.
Respond fast and do the slow work afterwards. A handler that calls three other APIs before answering will hit the 10 second limit.
How does anymail show the result?
The last ten deliveries are visible in the app, with the response and the error, and a dead endpoint never stops sending. The integrations page describes the webhook next to the REST API and the other connectors, and every plan includes it.
Frequently asked questions
Which header carries the signature?
X-Seegnals-Signature, in the form t=<unix seconds>,v1=<hex>. The signed string is the timestamp, a dot and the raw request body, with HMAC-SHA256 and your workspace signing secret.
How many times is a failed delivery retried?
A delivery gets 3 attempts in total, about a minute apart. Any response outside 2xx counts as a failure, redirects are not followed and the request times out after 10 seconds.
How do I avoid processing the same event twice?
Store the value of the X-Seegnals-Delivery header. It identifies the delivery and stays the same across retries, so a repeat with a known value can be acknowledged and skipped.
Is an open event sent by webhook?
No. Apple Mail Privacy Protection loads the tracking pixel for every message, so an open event would fire on noise. Sent, replied, bounced, unsubscribed and clicked events are sent.
See which companies are warming up.
Start free trial