Skip to Content
WebhooksReceiving events

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

TypeMeaning
email.sentHanded to the mail provider. Not yet delivered.
email.deliveredThe receiving server accepted it.
email.bouncedCame back. bounce_type Permanent and Undetermined suppress the address; Transient is informational.
email.complainedMarked as spam. The address is suppressed.
email.failedNever left. data.error says why.
suppression.createdAn 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-Attempt counts 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:

  1. Deploy a receiver that accepts either the old or the new secret.
  2. Rotate in the app, copy the new secret, deploy it.
  3. Drop the old secret from your config.
Last updated on