PristineSend
Get started
API Reference

Webhooks

Register an endpoint to receive delivery-status events from your workspace, so you can react to bounces, complaints, opens, and clicks in real time.

When an event fires, PristineSend POSTs the signed payload to each enabled endpoint subscribed to that event type. Failed deliveries are retried with exponential backoff; an endpoint that keeps failing is automatically disabled (re-enable it under Settings → Webhooks). The Events API exposes the same delivery history if you prefer to pull it.

Overview

Webhooks are HTTP POST requests sent to a URL you configure. They are triggered by delivery events from your PristineSend workspace. To set up a webhook endpoint, go to Settings → Webhooks in your dashboard and enter your endpoint URL.

Tip: During development, use ngrok or localtunnel to expose your local server to the internet.

Supported events

PristineSend pushes these six event types. They are the same set you can filter on when reading the delivery log via the Events endpoint.

Event typeDescription
email.deliveredThe receiving mail server confirmed delivery.
email.bouncedThe email could not be delivered (hard or soft bounce).
email.complainedRecipient marked the email as spam.
email.failedThe send failed before delivery (e.g. the provider rejected the message).
email.openedRecipient opened the email (requires open tracking).
email.clickedRecipient clicked a tracked link (requires click tracking).

Payload format

All events share a common envelope: a unique id (prefixed evt_, stable across retries), the type, a created_at timestamp, and a data object. data is the same email object returned by GET /v1/emails/:id (it is null for the rare event with no associated message):

{
  "id": "evt_3f2a9c1e-7b4d-4a8e-9f1c-2d6b0e5a8c44",
  "type": "email.delivered",
  "created_at": "2026-05-16T12: 34: 56.000Z",
  "data": {
    "id": "a1b2c3d4-5e6f-4708-91a2-b3c4d5e6f708",
    "to": "recipient@example.com",
    "subject": "Your order has shipped!",
    "status": "delivered",
    "type": "transactional",
    "environment": "live",
    "sent_at": "2026-05-16T12: 34: 50.000Z",
    "delivered_at": "2026-05-16T12: 34: 56.000Z",
    "opened_at": null,
    "clicked_at": null,
    "bounced_at": null,
    "error": null
  }
}

Verifying signatures

Every delivery is an HTTP POST with a JSON body and these headers:

HeaderValue
X-PristineSend-Signaturet=<unix>,v1=<hex> — the timestamp and signature (see below).
X-PristineSend-Event-IdThe event id (same as id in the body). Stable across retries — use it to dedupe.
X-PristineSend-Event-TypeThe event type, e.g. email.delivered.

The v1 signature is the hex HMAC-SHA256 of the string `${t}.${rawBody}` — the timestamp, a literal dot, and the exact raw request body — keyed by your endpoint's signing secret used verbatim: the entire whsec_… string, prefix included, is the HMAC key — don't strip the whsec_ prefix or base64-decode it. To verify a request:

  1. Read the X-PristineSend-Signature header and split out t and v1.
  2. Compute HMAC_SHA256(secret, `${t}.${rawBody}`) over the raw bytes you received — verify before JSON.parse, since re-serializing changes the bytes.
  3. Compare it to v1 with a constant-time check.
  4. Optionally reject the request if t is more than a few minutes old (replay protection).

Your endpoint's signing secret is shown when you create it and is retrievable any time from Settings → Webhooks. A worked example is below.

Example handler

A minimal Next.js App Router webhook handler that verifies the signature and dispatches on event type:

"color:#ff7b72">import { NextRequest, NextResponse } "color:#ff7b72">from "next/server"
"color:#ff7b72">import { createHmac, timingSafeEqual } "color:#ff7b72">from "node:crypto"

// Your endpoint's signing secret "color:#ff7b72">from Settings → Webhooks (starts with "whsec_").
"color:#ff7b72">const secret = process.env.PRISTINESEND_WEBHOOK_SECRET!

"color:#ff7b72">function verify(rawBody: string, header: string | null): boolean {
  if (!header) "color:#ff7b72">return false
  // Header form: "t=<unix>,v1=<hex>"
  "color:#ff7b72">const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=") "color:#ff7b72">as [string, string]))
  "color:#ff7b72">const t = parts.t
  "color:#ff7b72">const v1 = parts.v1
  if (!t || !v1) "color:#ff7b72">return false

  // Optional replay protection: reject signatures older than 5 minutes.
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) "color:#ff7b72">return false

  // The whole "whsec_…" string is the HMAC key; sign "<t>.<rawBody>".
  "color:#ff7b72">const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")
  "color:#ff7b72">const a = Buffer."color:#ff7b72">from(v1)
  "color:#ff7b72">const b = Buffer."color:#ff7b72">from(expected)
  "color:#ff7b72">return a.length === b.length && timingSafeEqual(a, b)
}

"color:#ff7b72">export "color:#ff7b72">async "color:#ff7b72">function POST(req: NextRequest) {
  // Verify the RAW body before parsing — re-serializing would change the bytes.
  "color:#ff7b72">const rawBody = "color:#ff7b72">await req.text()
  if (!verify(rawBody, req.headers.get("X-PristineSend-Signature"))) {
    "color:#ff7b72">return NextResponse.json({ error: "Invalid signature" }, { status: 400 })
  }

  "color:#ff7b72">const event = JSON.parse(rawBody) "color:#ff7b72">as { id: string; "color:#ff7b72">type: string; data: unknown }

  switch (event."color:#ff7b72">type) {
    case "email.delivered":
      // mark contact "color:#ff7b72">as active
      break
    case "email.bounced":
      // mark contact "color:#ff7b72">as bounced
      break
    case "email.complained":
      // unsubscribe contact
      break
  }

  "color:#ff7b72">return NextResponse.json({ received: true })
}