Questions · Updated 2026-09-27
How do I verify a webhook signature?
Verify a webhook signature by recomputing HMAC-SHA256 over the timestamp, a dot and the raw body with the signing secret POST /webhooks returned, then comparing it constant-time to the v1 value in the x-agentisend-signature header. POST /webhooks/{id}/rotate-secret mints a new secret.
Verify a webhook signature by reading the x-agentisend-signature header, whose value is t=<unix seconds>,v1=<hex>, recomputing HMAC-SHA256 with the endpoint's signing secret over <t>.<raw body>, and comparing your digest to v1 with a constant-time comparison. The secret is the one POST /webhooks returned when you registered the endpoint; the operation summary says "The signing secret is returned exactly once." Reject a delivery whose t is more than 5 minutes from your clock, and sign the raw body bytes as they arrived, not JSON you parsed and serialised again.
The headers on every delivery
x-agentisend-signature—t=<unix seconds>,v1=<hex>. During a secret rotation it carries twov1=entries, one per secret, and either one verifies.x-agentisend-event-id— the eventid, the value to deduplicate on.x-agentisend-event-type— the type, such asemail.bounced.x-agentisend-timestamp— the same unix seconds ast.x-agentisend-replay: true— present only on a delivery you asked for withPOST /webhooks/{id}/replay.
Webhooks says why the timestamp check matters: without it a captured delivery can be replayed at you forever.
A verifier in Node
rawBody is the request body exactly as received. In a framework that parses JSON for you, read the raw bytes before the parser runs.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 5 * 60;
export function verifyAgentiSendSignature(secret, header, rawBody) {
let timestamp = null;
const provided = [];
for (const part of header.split(',')) {
const [key, value] = part.split('=');
if (key === 't') timestamp = Number(value);
if (key === 'v1' && value) provided.push(value);
}
if (!timestamp || provided.length === 0) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest();
return provided.some((hex) => {
const candidate = Buffer.from(hex, 'hex');
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
});
}The Node SDK exports the same check: import { verifyWebhookSignature } from 'agentisend/webhook-signing' takes the secret, the header value and the raw body, and it is the implementation that signs the delivery.
A verifier written for Svix
Create the endpoint with svix_compat: true on POST /webhooks, or set it later with PATCH /webhooks/{id}, and each delivery also carries svix-id, svix-timestamp and svix-signature. That signature is v1,<base64> over <id>.<timestamp>.<body>, with the key being the base64-decoded bytes after whsec_ in the secret, so a verifier built for that scheme keeps working. The x-agentisend-* headers still arrive beside them. Migrating from Resend covers the rest of the move.
Rotating the secret
POST /webhooks/{id}/rotate-secret returns secret, previous_secret_expires_at and rotated_at. Its summary says "The previous secret keeps verifying for 24 hours, and deliveries in that window are signed with both." Store the new secret, deploy it, and the old one stops verifying at previous_secret_expires_at with nothing else to do.
curl -sS -X POST https://api.agentisend.com/webhooks/9c8f8f0e-3d1a-4d3f-9a1e-2b7c1a0f5e42/rotate-secret \
-H "Authorization: Bearer $AGENTISEND_API_KEY" \
-H "Idempotency-Key: $(uuidgen)"201
{
"id": "9c8f8f0e-3d1a-4d3f-9a1e-2b7c1a0f5e42",
"previous_secret_expires_at": "2026-09-04T09:14:00Z",
"rotated_at": "2026-09-04T09:14:00Z",
"secret": "string"
}