Coldfeet
Documentation

Webhooks

Webhooks let Coldfeet tell you about a message instead of you polling for it. Configure them in the console under Settings → Webhooks.

Events

EventFires when
email.verdictEvery message reaches a verdict.
email.blockedA message was blocked by policy.
quarantine.heldA message was quarantined.
quarantine.releasedA 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.

The request

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:

HeaderMeaning
X-Coldfeet-EventThe event name.
X-Coldfeet-DeliveryDelivery id, stable across retries of the same delivery.
X-Coldfeet-TimestampUnix seconds, regenerated per attempt.
X-Coldfeet-Signature-256t=<timestamp>,v1=<hex> — verify this one.
X-Coldfeet-SignatureLegacy body-only HMAC, kept for existing receivers.

Verifying a delivery

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.

Delivery, failure and retries

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 a secret

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

← All documentation