SendpositoryDocs
Guides

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.

The Webhooks page

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

EventWhen
email.scheduledAccepted and held for a future time.
email.sentHanded to the mail provider.
email.deliveredAccepted by the recipient's mailbox.
email.delayedTemporary failure; we'll retry.
email.bouncedPermanent failure. The address is suppressed.
email.complainedMarked as spam. The address is suppressed.
email.failedCouldn't be sent at all.
email.openedThe recipient opened it.
email.clickedA link was clicked.
email.unsubscribedThe recipient used one-click unsubscribe; the address is now suppressed. See unsubscribe.
email.receivedMail 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.

On this page