Webhooks
The API lets your code call day3. Webhooks let day3 call your code.
If you send through day3 and take no events, your app cannot know that an address
hard-bounced. day3 stops mailing it, because the suppression list is enforced on
our side, but your database still says the address is good. You keep showing
“we sent you an email”, keep retrying, and have no suppression state to reason
about. suppression.created is the event that closes that gap.
This page is for the person writing the receiver. To provision endpoints from code, see Managing endpoints.
Setting one up
In the app: API keys, then Webhooks, then Add endpoint. Pick the events, save, and copy the signing secret.
Unlike an API key, the signing secret is readable again later. It lives in your deploy config, and losing it should not mean an outage.
Endpoint URLs must be public https on port 443 or 8443. Loopback addresses,
private ranges and cloud-metadata addresses are refused, validated at the
socket’s own DNS lookup, so a hostname that re-resolves to a private address
after you save it still will not be reached. Redirects are never followed, so
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
newsletter sends. The campaign shape carries campaign_id, recipient_id and
contact_id instead of email_id and to. Join on those if you need the
campaign’s name or subject: we do not inline them, because that would mean an
extra read on every message we send.
A transactional message can carry up to 50 recipients, and the provider reports
per address, so you get one event per affected address, each with its own
data.email, all sharing a 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. Check bounce_type: Permanent and Undetermined suppress the address, Transient is informational. |
email.complained | Recipient marked it as spam. The address is suppressed. |
email.failed | Never left. data.error says why. |
suppression.created | An address was added to the suppression list. data.reason is one of hard_bounce, complaint, unsubscribe, manual, provider_suppressed. |
Verifying the signature
Day3-Signature is t=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256
over the exact string `${t}.${rawBody}`, keyed with your endpoint’s signing
secret.
Verify against the raw request body, before any JSON parsing. A framework that parses and re-serializes changes the bytes, and then 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() gives you 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 quickly. Any 2xx counts as delivered. We time out after 10 seconds. Do the real work after responding, in a queue or a background job. A handler that processes inline will eventually time out under load and get retried, which is the failure mode retries are supposed to fix rather than cause.
Dedupe on id. Delivery is at-least-once. In practice duplicates are rare:
every event is emitted from inside the same database guard that makes the
underlying write idempotent, so a provider redelivery or a retried job does not
re-emit it. But a worker that crashes mid-POST is retried by design, and that is
where you would see the same id twice. Storing processed event ids for a day or
two is enough.
Do not assume ordering. Events are delivered independently. A delivered and
a bounced for the same message can arrive out of order. Trust the row you build
from all of them, not the last one you saw.
Retries
Seven attempts: the first, then six retries at 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour and 6 hours, spanning a little under eight hours.
Anything that is not 2xx is retried, including 3xx, because we do not follow
redirects. Day3-Delivery-Attempt counts from 1.
After the last attempt the delivery is marked failed and stays in the log, where you can hit Resend. That re-sends the original payload, so the signature still verifies.
An endpoint that fails repeatedly is not auto-disabled. A silently disabled webhook looks healthy while your data quietly drifts out of sync, which is worse than a noisy failing one. The endpoint list shows the failure streak and the last error instead.
Delivery rows are kept for 30 days.
Rotating the secret
We sign with exactly one secret, so rotation is not automatically zero-downtime. This order is:
- Deploy a receiver that accepts either the old or the new secret.
- Rotate in the day3 UI, copy the new secret, deploy it.
- Drop the old secret from your config.