Webhooks
Get delivery events pushed to your app and verify they came from us, with the signature scheme and a handler that stays fast.
Updated
Rather than polling for status, register an endpoint and we POST to it as events happen.

Adding an endpoint
Webhooks → Add an endpoint, or through the API. You get a signing secret, shown once:
whsec_9f2a...Leave the event list empty to receive everything.
Events
| Event | When |
|---|---|
email.scheduled | Accepted and held for a future time. |
email.sent | Handed to the mail provider. |
email.delivered | Accepted by the recipient's mailbox. |
email.delayed | Temporary failure; we'll retry. |
email.bounced | Permanent failure. The address is suppressed. |
email.complained | Marked as spam. The address is suppressed. |
email.failed | Couldn't be sent at all. |
email.opened | The recipient opened it. |
email.clicked | A link was clicked. |
email.unsubscribed | The recipient used one-click unsubscribe; the address is now suppressed. See unsubscribe. |
email.received | Mail arrived at one of your receiving addresses. |
The payload
{
"type": "email.delivered",
"created_at": "2026-08-30T09:12:44.019Z",
"data": {
"email_id": "d67e39ed-96be-4b35-ab22-853163d92c92",
"from": "hello@mail.yourdomain.com",
"to": ["customer@example.com"],
"subject": "Your code is 481920",
"status": "delivered",
"tags": { "type": "otp" }
}
}Opens and clicks carry more
email.opened and email.clicked add what actually happened, so you don't
have to call the API back to find out:
{
"type": "email.clicked",
"created_at": "2026-08-30T09:19:31.006Z",
"data": {
"email_id": "d67e39ed-96be-4b35-ab22-853163d92c92",
"to": ["customer@example.com"],
"subject": "This month at Acme",
"status": "delivered",
"url": "https://yourdomain.com/pricing",
"clicked_at": "2026-08-30T09:19:31.006Z",
"click_count": 1,
"user_agent": "Mozilla/5.0 ..."
}
}email.opened carries opened_at, open_count and user_agent in the same
way. Counts exclude scanners and privacy proxies, so a second open is a second
person-open. See Open tracking on what these are
worth.
The recipient's IP address is never sent. We record it to tell a proxy from a person; passing it on would hand you a location for somebody who only opened an email.
Verifying the signature
Every request carries a Sendpository-Signature header:
Sendpository-Signature: t=1788026132,v1=4f3a9c...v1 is an HMAC-SHA256 of "{timestamp}.{raw body}" using your signing secret.
Verify before you trust it
Your endpoint is a public URL - anyone can POST to it. Without verification, anyone could tell your app an email bounced.
Use the raw request body, exactly as received. Parsing the JSON and re-serialising it changes the bytes, and the signature won't match.
Use the SDK
verifyWebhook does all of this - constant-time comparison, and rejecting
anything older than five minutes so a captured request can't be replayed at you
later. It throws on a bad signature and returns the parsed event on a good one.
import { verifyWebhook } from "sendpository";
export async function POST(req: Request) {
const raw = await req.text();
try {
const event = verifyWebhook({
body: raw,
signature: req.headers.get("sendpository-signature") ?? "",
secret: process.env.SENDPOSITORY_WEBHOOK_SECRET!,
});
// event.type is "email.delivered", "email.bounced", ...
return new Response("ok");
} catch {
return new Response("Invalid signature", { status: 401 });
}
}isValidWebhook is the same check returning a boolean, if you would rather not
catch.
By hand
Only if you are not on JavaScript. Getting the replay window or the constant-time compare wrong is the whole risk here, so prefer the helper above where you can.
import crypto from "node:crypto";
export function verify(rawBody: string, header: string, secret: string) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
// Reject anything older than five minutes, so a captured request
// can't be replayed against you later.
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
// Constant-time compare - a plain === leaks timing information.
return crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}In a Next.js route handler:
export async function POST(req: Request) {
const raw = await req.text();
const sig = req.headers.get("sendpository-signature") ?? "";
if (!verify(raw, sig, process.env.SENDPOSITORY_WEBHOOK_SECRET!)) {
return new Response("Invalid signature", { status: 401 });
}
const event = JSON.parse(raw);
// ... handle it
return new Response("ok");
}Responding
Return any 2xx as soon as you've stored the event. Do the real work
afterwards - we time out after 10 seconds, and a slow endpoint counts as a
failure.
When your endpoint fails
Every attempt is recorded with its status code, visible on the Webhooks page. After 15 consecutive failures an endpoint is disabled rather than retried forever. Fix the receiver and re-enable it; the failure count resets.