Webhooks let Coldfeet tell you about a message instead of you polling for it. Configure them in the console under Settings → Webhooks.
| Event | Fires when |
|---|---|
email.verdict | Every message reaches a verdict. |
email.blocked | A message was blocked by policy. |
quarantine.held | A message was quarantined. |
quarantine.released | A quarantined message was released. |
The Send test button delivers a webhook.test event to one endpoint regardless of what it
subscribes to, so you can prove the plumbing before mail depends on it.
POST to your HTTPS URL — plain HTTP is refused when the endpoint is created — with this body:
{
"event": "email.verdict",
"tenantId": "t_123",
"occurredAt": "2026-01-14T09:31:52.184Z",
"payload": {}
}
Alongside Content-Type, each delivery carries:
| Header | Meaning |
|---|---|
X-Coldfeet-Event | The event name. |
X-Coldfeet-Delivery | Delivery id, stable across retries of the same delivery. |
X-Coldfeet-Timestamp | Unix seconds, regenerated per attempt. |
X-Coldfeet-Signature-256 | t=<timestamp>,v1=<hex> — verify this one. |
X-Coldfeet-Signature | Legacy body-only HMAC, kept for existing receivers. |
The secret is 64 hex characters, shown when the endpoint is created and when you rotate it.
v1 is an HMAC-SHA256, hex encoded, over the timestamp, a literal period, and the raw request
body:
const [, timestamp, signature] = header.match(/t=(\d+),v1=([a-f0-9]+)/);
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const ok =
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)) &&
Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
Sign the bytes as received, before any JSON parse and re-serialize — reformatting the body changes
the digest. Compare with a constant-time function, and reject a timestamp too far from now so a
captured delivery cannot be replayed later. The older X-Coldfeet-Signature covers the body
alone and has no such protection, which is why it should not be the one you check.
Any 2xx marks the delivery complete. Anything else — including a redirect, which is not
followed — is a failure, and so is taking longer than 10 seconds to respond.
A failed delivery is retried up to 8 attempts with exponential backoff from 30 seconds, spanning roughly two hours before it is abandoned. Every attempt is recorded, and a delivery can be retried by hand from the console for 30 days, after which the record is purged.
Two consequences worth designing for. Respond 2xx as soon as you have the payload and do the
work afterwards, or a slow handler will look like an outage and be retried. And treat deliveries as
at-least-once: a response lost in transit is retried with the same X-Coldfeet-Delivery, so
deduplicate on that id.
Rotating issues a new secret immediately; deliveries signed with the old one stop at once. Accept both for the length of a deploy if you cannot swap the receiver's copy at the same moment.
Last updated