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.

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);
});

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.

What it checks

The SDKs return the event only when every check passes:

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

The verified event is the event envelope:

idstring

Event ID. Use it to deduplicate: retries and replays carry the same id.

typestring

Event type, such as email.delivered. See Event types.

schema_versionnumber

Version of the data shape for this event type.

dataobject

The event's fields, documented on each event's page.

created_atstring

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

payloadrequiredstring or Buffer

The raw request body, exactly as received.

headersrequiredobject

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.

secretrequiredstring

The endpoint's signing secret, starting with whsec_.

toleranceoptionalnumber

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:

reasonMeaning
missing_headersA signature header is missing. The request may not come from Nexiom Connect.
invalid_timestampThe timestamp header is not a Unix time in seconds.
timestamp_out_of_toleranceThe request is older or newer than the tolerance allows. Check your server's clock.
invalid_signatureThe signature doesn't match. Check the secret and that the body was not changed.
invalid_payloadThe 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:

FrameworkHow to read the raw body
Expressexpress.raw({ type: 'application/json' }) on the webhook route, before express.json() applies.
FastifyRegister a content-type parser with parseAs: 'buffer' for the webhook route.
Next.js route handlersawait request.text(), and pass request.headers.
Hono and Cloudflare Workersawait 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 PHPfile_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:

  1. Read the headers

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

  2. Check the timestamp

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

  3. 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)
  4. Compare in constant time

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

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);
}

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

Nexiom Connect
Open dashboard