> ## 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.

# Handle Webhook Events

> Implement an HTTPS endpoint to receive and verify Nuntly webhook events. Includes Next.js and Express code examples with signature verification.

<Note>To create and manage webhooks, see the [webhooks guide](/docs/guides/webhooks).</Note>

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](/docs/guides/webhooks).
* 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:

| Header              | Description                                                        |
| ------------------- | ------------------------------------------------------------------ |
| `webhook-timestamp` | Unix timestamp in milliseconds when the request was signed         |
| `webhook-signature` | Signature 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.

<Tip>Use a timing-safe comparison function (such as `crypto.timingSafeEqual` in Node.js) when comparing signatures to avoid timing attacks.</Tip>

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

<CodeGroup>
  ```typescript Next.js (App Router) theme={null}
  // 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' },
    });
  }
  ```

  ```typescript Express (Node.js) theme={null}
  import express from 'express';
  import crypto from 'crypto';

  const app = express();
  const TOLERANCE_SECONDS = 5 * 60;

  function verifySignature(payload: string, signatureHeader: string, secret: string): boolean {
    const rawKey = secret.replace(/^whsec_/, '');
    const parts = signatureHeader.split(',');
    const timestamp = parts[0]!.replace('t=', '');
    const signatures = parts.slice(1).map((s) => s.replace('v0=', ''));

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

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

    return signatures.some(
      (sig) =>
        sig.length === expected.length &&
        crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
    );
  }

  // Use express.text() on this route to preserve the raw body for signature verification
  app.post('/api/webhooks/nuntly', express.text({ type: 'application/json' }), (req, res) => {
    const payload = req.body;
    const signatureHeader = (req.headers['webhook-signature'] as string) || '';
    const secret = process.env.NUNTLY_WEBHOOK_SIGNING_SECRET!;

    if (!verifySignature(payload, signatureHeader, secret)) {
      return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(payload);

    switch (event.type) {
      case 'email.delivered':
        console.log(`Email ${event.data.id} was delivered`);
        break;
      case 'email.bounced':
        console.log(`Email ${event.data.id} bounced`);
        break;
      case 'email.complained':
        console.log(`Email ${event.data.id} was marked as spam`);
        break;
      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

    res.json({ received: true });
  });
  ```
</CodeGroup>

<Note>
  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.
</Note>

## 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](/docs/guides/observability) page.

## Learn more

<CardGroup cols={2}>
  <Card title="Set up webhooks" icon="webhook" href="/docs/guides/webhooks">
    Create, edit, and configure webhooks from the dashboard or SDK
  </Card>

  <Card title="Monitor your activity" icon="chart-line" href="/docs/guides/observability">
    Track webhook delivery status and email event logs
  </Card>

  <Card title="What is a webhook endpoint?" icon="newspaper" href="https://nuntly.com/blog/what-is-a-webhook-endpoint">
    A developer's guide to the concept, signature verification, and idempotency
  </Card>
</CardGroup>
