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.
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:
// 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:
| 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:
idstringEvent ID. Use it to deduplicate: retries and replays carry the same
id.typestringEvent type, such as
email.delivered. See Event types.schema_versionnumberVersion of the
datashape for this event type.dataobjectThe event's fields, documented on each event's page.
created_atstringWhen 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 BufferThe raw request body, exactly as received.
headersrequiredobjectThe 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()orgetallheaders(). Header names are matched case-insensitively.secretrequiredstringThe endpoint's signing secret, starting with
whsec_.toleranceoptionalnumberAllowed clock difference in seconds. Defaults to
300. Pass it as{ tolerance: 600 }in Node.js, or as a named argumenttolerance: 600in 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, andx-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.HMAC-SHA256(secret, timestamp + "." + rawBody)Compare in constant time
Compare your hexadecimal result with
x-nexiom-signatureusing 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.