Skip to main content

Documentation Index

Fetch the complete documentation index at: https://nuntly.com/docs/llms.txt

Use this file to discover all available pages before exploring further.

To create and manage webhooks, see the webhooks guide.
This guide shows how to set up an HTTPS endpoint in your application to receive webhook events from Nuntly, verify their authenticity using the signing secret, and handle each event type.

Prerequisites

  • A webhook created in your Nuntly account. See the webhooks guide.
  • The signing secret for that webhook (displayed once at creation)
  • An HTTPS endpoint on a publicly accessible server

Webhook headers

Every request Nuntly sends includes two signature headers:
HeaderDescription
webhook-timestampUnix timestamp in milliseconds when the request was signed
webhook-signatureSignature string in the format t={unix_seconds},v0={sig1},{sig2}
The t= value is the signing timestamp in seconds. The values after v0= are one or more HMAC-SHA256 signatures encoded as hex strings. During a secret rotation, both the current and previous secret sign each delivery, so you may see two signatures separated by a comma.

Verify the webhook signature

You must verify the signature before processing the event. This prevents your endpoint from acting on forged requests. How verification works:
  1. Remove the whsec_ prefix from your signing secret to get the raw key.
  2. Parse the webhook-signature header to extract the timestamp (t= value) and the hex signatures (after v0=).
  3. Build the signed content: {timestamp}.{raw_body} (the t= value, a dot, then the raw request body).
  4. Compute HMAC-SHA256(raw_key, signed_content) and hex-encode the result.
  5. Compare your computed signature against each signature from step 2. Accept if any one matches.
Use a timing-safe comparison function (such as crypto.timingSafeEqual in Node.js) when comparing signatures to avoid timing attacks.
You can also use the timestamp for replay protection: reject any request where the signing timestamp is more than five minutes old.

Implement the endpoint

// app/api/webhooks/nuntly/route.ts
import type { NextRequest } from 'next/server';
import crypto from 'crypto';

const TOLERANCE_SECONDS = 5 * 60;

function verifySignature(payload: string, signatureHeader: string, secret: string): boolean {
  // Remove the whsec_ prefix
  const rawKey = secret.replace(/^whsec_/, '');

  // Parse the signature header: t=<seconds>,v0=<hex1>,<hex2>
  const parts = signatureHeader.split(',');
  const timestamp = parts[0]!.replace('t=', '');
  const signatures = parts.slice(1).map((s) => s.replace('v0=', ''));

  // Replay protection
  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (age > TOLERANCE_SECONDS) {
    return false;
  }

  // Compute HMAC-SHA256
  const signedContent = `${timestamp}.${payload}`;
  const expected = crypto.createHmac('sha256', rawKey).update(signedContent).digest('hex');

  // Accept if any signature matches
  return signatures.some(
    (sig) =>
      sig.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
  );
}

export async function POST(req: NextRequest) {
  const payload = await req.text();
  const signatureHeader = req.headers.get('webhook-signature') || '';
  const secret = process.env.NUNTLY_WEBHOOK_SIGNING_SECRET!;

  if (!verifySignature(payload, signatureHeader, secret)) {
    return new Response('Invalid signature', { status: 401 });
  }

  const event = JSON.parse(payload);

  switch (event.type) {
    case 'email.delivered':
      console.log(`Email ${event.data.id} was delivered`);
      break;
    case 'email.bounced':
      // Consider removing the address from your sending list
      console.log(`Email ${event.data.id} bounced`);
      break;
    case 'email.complained':
      // Stop sending to this address
      console.log(`Email ${event.data.id} was marked as spam`);
      break;
    case 'email.opened':
      console.log(`Email ${event.data.id} was opened`);
      break;
    case 'email.clicked':
      console.log(`Email ${event.data.id} had a link clicked`);
      break;
    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

  // Return 200 immediately. Do heavy processing asynchronously.
  return new Response(JSON.stringify({ received: true }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' },
  });
}
If you use express.json() as global middleware, it will consume the raw body before your route handler runs. Apply express.text() specifically to your webhook route, as shown above.

Best practices

  • Respond quickly. Return a 200 response as soon as you receive and verify the event. If processing takes time, queue the work for background execution.
  • Always verify the signature. Never skip signature verification, even in development.
  • Handle duplicates. Nuntly may retry failed deliveries. Use the event ID to deduplicate if needed.
  • Log raw payloads. Store the raw event body for debugging and auditing.
  • Handle bounces and complaints. When you receive an email.bounced or email.complained event, stop sending to that address to protect your sender reputation.

Monitor webhook delivery

You can see whether your webhook events are being delivered successfully in the Webhooks tab of the Observability page.

Learn more

Set up webhooks

Create, edit, and configure webhooks from the dashboard or SDK

Monitor your activity

Track webhook delivery status and email event logs