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. Gated by open tracking: fires normally under full; under essential the open is day-granular (the payload's opened_at is a date at midnight UTC); under off it NEVER fires. See Open-tracking modes below.
email.clickedRecipient clicked a tracked link. Fires under full and essential (clicks are full-fidelity in both); under off it NEVER fires.
Open-tracking modes affect which events you receive. Each message carries an open-tracking mode (full / essential / off), reported on every payload as data.open_tracking_mode. Under off, email.opened and email.clicked never fire — so don't treat their absence as "never opened". Under essential, email.opened still fires but its opened_at is a day-granular date (midnight UTC), and it fires once per message; email.clicked is unaffected. email.delivered, email.bounced, email.complained, and email.failed fire in every mode.

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,
    "open_tracking_mode": "full",
    "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 })
}