# Verify webhook requests

> Confirm that every event comes from Nexiom Connect with one SDK call, and get a typed event back.

Anyone can send a request to your webhook URL. Before you act on an event, confirm that Nexiom Connect sent it and that nobody changed it on the way. Every delivery is signed with your endpoint's signing secret; the SDKs check the signature, the timestamp, and the payload in one call.

## Verify with the SDK

Pass the raw request body, the request headers, and your signing secret to `webhooks.constructEventAsync` in the Node.js SDK (0.4.0 or later) or `webhooks->constructEvent` in the PHP SDK (0.4.0 or later). It returns the event when the request is genuine, and fails when it isn't. It makes no network request: Node.js verifies the signature with the runtime's Web Crypto API, which is why its method is asynchronous, and PHP with `hash_hmac`.

```typescript title="Node.js"
import express from 'express';
import { NexiomConnect, NexiomWebhookVerificationError } from '@nexiom/connect';

const nexiom = new NexiomConnect({ apiKey: process.env.NEXIOM_API_KEY });
const secret = process.env.NEXIOM_WEBHOOK_SECRET;

const app = express();

// Keep the body as raw bytes: parsed and re-serialized JSON no longer matches the signature.
app.post('/webhooks/nexiom', express.raw({ type: 'application/json' }), async (request, response) => {
  let event;

  try {
    event = await nexiom.webhooks.constructEventAsync(request.body, request.headers, secret);
  } catch (error) {
    if (error instanceof NexiomWebhookVerificationError) {
      return response.status(400).send(error.reason);
    }

    throw error;
  }

  switch (event.type) {
    case 'email.delivered':
      console.log(`Delivered to ${event.data.recipient}`);
      break;
    case 'whatsapp.message.failed':
      console.log(`WhatsApp message ${event.data.id} failed: ${event.data.failure_code}`);
      break;
    default:
      console.log(`Unhandled event type ${event.type}`);
  }

  response.sendStatus(204);
});
```

```php title="PHP"
<?php

// routes/api.php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
use Nexiom\Connect\Exceptions\NexiomWebhookVerificationException;
use Nexiom\Connect\NexiomConnect;

Route::post('/webhooks/nexiom', function (Request $request) {
    $nexiom = new NexiomConnect(config('services.nexiom.key'));

    try {
        // The raw body, exactly as received: never $request->all() or $request->json().
        $event = $nexiom->webhooks->constructEvent(
            $request->getContent(),
            $request->headers->all(),
            config('services.nexiom.webhook_secret'),
        );
    } catch (NexiomWebhookVerificationException $exception) {
        return response($exception->reason, 400);
    }

    switch ($event['type']) {
        case 'email.delivered':
            Log::info('Delivered to ' . $event['data']['recipient']);
            break;
        case 'whatsapp.message.failed':
            Log::warning('WhatsApp message ' . $event['data']['id'] . ' failed: ' . $event['data']['failure_code']);
            break;
        default:
            Log::info('Unhandled event type ' . $event['type']);
    }

    return response()->noContent();
});
```

In Laravel, define the route in `routes/api.php` (on Laravel 11 and later, run `php artisan install:api` once to create it). Those routes skip CSRF protection and are served under `/api`, so this endpoint's URL is `https://your-app.example/api/webhooks/nexiom`. Add the API key and signing secret to `config/services.php`, from your `.env`:

```php
// config/services.php

'nexiom' => [
    'key' => env('NEXIOM_API_KEY'),
    'webhook_secret' => env('NEXIOM_WEBHOOK_SECRET'),
],
```

Respond with a `2xx` status quickly, then do the work. To make processing reliable, store the event before you respond; see [Webhook ingester](/docs/webhooks/ingester).

## What it checks

The SDKs return the event only when every check passes:

| Check | Fails when |
| --- | --- |
| Signature headers | `x-nexiom-signature` or `x-nexiom-timestamp` is missing or malformed. |
| Timestamp | The timestamp is more than 5 minutes before or after your server's clock. This blocks replays of old requests. |
| Signature | The HMAC-SHA256 of the timestamp and body, with your secret, doesn't match. The comparison takes constant time. |
| Payload | The body is not a JSON event envelope with an `id`, `type`, `schema_version`, `data`, and `created_at`. |

The verified event is the [event envelope](/docs/webhooks#event-envelope):

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Event ID. Use it to deduplicate: retries and replays carry the same `id`. |
| `type` | string | Event type, such as `email.delivered`. See [Event types](/docs/webhooks/event-types). |
| `schema_version` | number | Version of the `data` shape for this event type. |
| `data` | object | The event's fields, documented on each event's page. |
| `created_at` | string | When the event was created, in ISO 8601. |

In TypeScript, the result is a union of every documented event: checking `event.type` narrows `event.data` to that event's fields. An event type added after your SDK version is still verified and returned, so keep a `default` branch.

### Options

| Parameter | Type | Description |
| --- | --- | --- |
| `payload` | string or Buffer · required | The raw request body, exactly as received. |
| `headers` | object · required | The request headers. Node.js accepts Node, Fetch API `Headers`, or a plain object. PHP accepts an array with string or list values, such as Laravel's `$request->headers->all()` or `getallheaders()`. Header names are matched case-insensitively. |
| `secret` | string · required | The endpoint's signing secret, starting with `whsec_`. |
| `tolerance` | number · optional | Allowed clock difference in seconds. Defaults to `300`. Pass it as `{ tolerance: 600 }` in Node.js, or as a named argument `tolerance: 600` in PHP. |

### Errors

A failed check rejects with `NexiomWebhookVerificationError` in Node.js and throws `NexiomWebhookVerificationException` in PHP. Its `reason` says which check failed:

| `reason` | Meaning |
| --- | --- |
| `missing_headers` | A signature header is missing. The request may not come from Nexiom Connect. |
| `invalid_timestamp` | The timestamp header is not a Unix time in seconds. |
| `timestamp_out_of_tolerance` | The request is older or newer than the tolerance allows. Check your server's clock. |
| `invalid_signature` | The signature doesn't match. Check the secret and that the body was not changed. |
| `invalid_payload` | The signature matches, but the body is not an event envelope. |

Respond `400` to a request that fails verification, and never process it.

## Get the raw body

The signature covers the exact bytes Nexiom Connect sent. Read the body before anything parses it:

| Framework | How to read the raw body |
| --- | --- |
| Express | `express.raw({ type: 'application/json' })` on the webhook route, before `express.json()` applies. |
| Fastify | Register a `content-type` parser with `parseAs: 'buffer'` for the webhook route. |
| Next.js route handlers | `await request.text()`, and pass `request.headers`. |
| Hono and Cloudflare Workers | `await c.req.text()` or `await request.text()`. |
| Laravel | `$request->getContent()`, and `$request->headers->all()` for the headers. |
| Symfony | `$request->getContent()`, and `$request->headers->all()` for the headers. |
| Plain PHP | `file_get_contents('php://input')`, and `getallheaders()`. |

## Signing secrets

Each webhook endpoint has its own signing secret, starting with `whsec_`. It is shown once, when you create the endpoint in the dashboard. Store it with your other server secrets, and give each environment its own endpoint and secret.

Your API key is not a signing secret, and a signing secret cannot call the API. Never put either in client-side code or logs.

## Verify without an SDK

In other languages, verify the request yourself:

### Read the headers

Read `x-nexiom-timestamp`, the Unix time in seconds of this delivery attempt, and `x-nexiom-signature`, a hexadecimal HMAC-SHA256.

### Check the timestamp

Reject the request when the timestamp is more than 5 minutes away from your server's clock, in either direction.

### Compute the signature

Compute HMAC-SHA256 over the timestamp, a period, and the raw body, using the full signing secret as the UTF-8 key. Keep the `whsec_` prefix; don't decode it.

```text
HMAC-SHA256(secret, timestamp + "." + rawBody)
```

### Compare in constant time

Compare your hexadecimal result with `x-nexiom-signature` using a constant-time comparison, then parse the JSON.

```typescript title="Node.js"
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyWebhook(rawBody: Buffer, timestamp: string, signature: string, secret: string): boolean {
  if (!secret || !/^\d+$/.test(timestamp) || !/^[a-f0-9]{64}$/i.test(signature)) {
    return false;
  }

  const seconds = Number(timestamp);
  const now = Math.floor(Date.now() / 1000);

  if (!Number.isSafeInteger(seconds) || Math.abs(now - seconds) > 300) {
    return false;
  }

  const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();
  const received = Buffer.from(signature, 'hex');

  return received.length === expected.length && timingSafeEqual(received, expected);
}
```

```php title="PHP"
<?php

function verifyWebhook(string $rawBody, string $timestamp, string $signature, string $secret): bool
{
    if ($secret === '' || !ctype_digit($timestamp) || !preg_match('/^[a-f0-9]{64}$/i', $signature)) {
        return false;
    }

    if (abs(time() - (int) $timestamp) > 300) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

    return hash_equals($expected, strtolower($signature));
}
```

```python title="Python"
import hashlib
import hmac
import time

def verify_webhook(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
    if not secret or not timestamp.isdigit() or len(signature) != 64:
        return False

    if abs(time.time() - int(timestamp)) > 300:
        return False

    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()

    return hmac.compare_digest(expected, signature.lower())
```

## Troubleshooting

### Every request fails with `invalid_signature`

The body was changed before verification, or the secret is wrong. Make sure your framework doesn't parse the JSON first: whitespace, key order, and newlines all change the signature. Check that the secret is the one shown for this endpoint, with its `whsec_` prefix, and not your API key.

### Requests fail with `timestamp_out_of_tolerance`

Your server's clock is off. Synchronize it with NTP. Each retry carries a fresh timestamp, so a retried event verifies even when its `created_at` is older than 5 minutes.

### I fixed my receiver; how do I get the events it rejected?

Rejected deliveries are retried automatically for a while. After the final attempt, [replay them](/docs/webhooks/retries-and-replays) from the dashboard.

### The other `x-nexiom-*` headers are not verified

Only the timestamp and body are signed. Use the event's `id` and `type` from the verified body, not from the `x-nexiom-event-id` and `x-nexiom-event-type` headers, which are for diagnostics.

---

Source: https://connect.nxiom.com/docs/webhooks/verify-webhooks-requests
