Webhooks
The API lets your code call day3. Webhooks let day3 call your code.
Without events your app never learns that an address hard-bounced. day3 stops
mailing it, but your database still says it is good. suppression.created
closes that gap.
This page is for whoever writes the receiver. To provision endpoints from code, see Managing endpoints.
Set one up
In the app: API keys, then Webhooks, then Add endpoint. Pick the events, save, copy the signing secret. Unlike an API key, the secret can be revealed again later in the app.
Endpoint URLs must be public https on port 443 or 8443.
- Loopback, private ranges and cloud-metadata addresses are refused. The check runs at the socket’s own DNS lookup, so a hostname that later re-resolves to a private address is still blocked.
- Redirects are never followed. Configure the final URL.
- For local development, use a tunnel such as
ngrok http 3000.
The request
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Day3-Webhooks/1.0 (+https://day3.app)
Day3-Event-Id: evt_2k4h9x4v0dz1
Day3-Event-Type: email.bounced
Day3-Delivery-Attempt: 1
Day3-Signature: t=1755264000,v1=5f3a...{
"id": "evt_2k4h9x4v0dz1",
"type": "email.bounced",
"created_at": "2026-08-15T09:14:03.221Z",
"data": {
"object": "email",
"email_id": "eml_7p2k9x4v0dz1",
"to": ["user@example.com"],
"email": "user@example.com",
"subject": "Reset your password",
"provider_message_id": "0100019...",
"bounce_type": "Permanent",
"bounce_subtype": "General"
}
}data.object is email for transactional sends and campaign_recipient for
campaign sends. The campaign shape carries campaign_id, recipient_id and
contact_id instead of email_id and to. The campaign’s name and subject are
not inlined; join on campaign_id.
A transactional message can have up to 50 recipients. You get one event per
affected address, each with its own data.email, sharing one
provider_message_id.
Events
| Type | Meaning |
|---|---|
email.sent | Handed to the mail provider. Not yet delivered. |
email.delivered | The receiving server accepted it. |
email.bounced | Came back. bounce_type Permanent and Undetermined suppress the address; Transient is informational. |
email.complained | Marked as spam. The address is suppressed. |
email.failed | Never left. data.error says why. |
suppression.created | An address was suppressed. data.reason is hard_bounce, complaint, unsubscribe, manual or provider_suppressed. |
Verify the signature
Day3-Signature is t=<unix seconds>,v1=<hex>. The hex is HMAC-SHA256 of
`${t}.${rawBody}`, keyed with the endpoint’s signing secret.
Verify the raw request body, before JSON parsing. Parsing and re-serializing changes the bytes, and every signature fails.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyDay3Signature(
header: string | undefined,
rawBody: string,
secret: string,
toleranceSeconds = 300,
): boolean {
if (!header) return false;
let t: number | undefined;
const provided: string[] = [];
for (const part of header.split(",")) {
const [k, v] = part.split("=", 2);
if (k?.trim() === "t") t = Number(v);
else if (k?.trim() === "v1") provided.push(v.trim());
}
if (!t || !Number.isFinite(t) || provided.length === 0) return false;
// Bound replay. Pick your own tolerance; we do not pick it for you.
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return provided.some((sig) => {
if (sig.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"));
});
}In a Next.js route handler, await req.text() is the raw body:
export async function POST(req: Request) {
const raw = await req.text();
const ok = verifyDay3Signature(
req.headers.get("day3-signature") ?? undefined,
raw,
process.env.DAY3_WEBHOOK_SECRET!,
);
if (!ok) return new Response("bad signature", { status: 401 });
const event = JSON.parse(raw);
// Enqueue and return fast. See below.
return new Response("ok");
}What your handler must do
Return 2xx fast. Any 2xx counts as delivered. We time out after 10 seconds. Queue the real work and respond. Inline processing times out under load and gets retried.
Dedupe on id. Delivery is at-least-once. Duplicates are rare, but a worker
that crashes mid-POST is retried by design. Keep processed ids for a day or two.
Do not assume order. Events are delivered independently, so delivered and
bounced for one message can arrive in either order. Trust the state you build
from all of them, not the last one.
Retries
Seven attempts: the first, then retries after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour and 6 hours. A little under eight hours in total.
- Anything not 2xx is retried, including 3xx, since redirects are not followed.
Day3-Delivery-Attemptcounts from 1.- After the last attempt the delivery is marked failed and stays in the log. Hit Resend there to re-send the original payload; the signature still verifies.
- Failing endpoints are never auto-disabled. A silently disabled webhook looks healthy while your data drifts. The endpoint list shows the failure streak and last error instead.
- Delivery rows are kept for 30 days.
Rotate the secret
We sign with one secret at a time. For zero downtime:
- Deploy a receiver that accepts either the old or the new secret.
- Rotate in the app, copy the new secret, deploy it.
- Drop the old secret from your config.