Verify signatures
Check the X-ElaraOne-Signature header on every CRM webhook, with tested examples for Node, Express, Next.js, PHP and Laravel.
Every CRM webhook is signed with the endpoint's secret, so your receiver can prove a request came from Elara One and was not changed or replayed. Check the signature before you trust anything in the body.
The signature
X-ElaraOne-Signature: t=1792834200,v1=99a914898dda783f7dc723607b20ad0e2e250a8f00e6af1f3aa7b52a8b0f74a9tis the Unix time, in seconds, when this attempt was signed. Each retry is signed afresh.v1is the lower-case hex HMAC-SHA256 of the stringt+.+ the raw request body, keyed with the endpoint's secret.- The key is the whole secret exactly as shown in the admin,
whsec_prefix included.
To verify:
- Split the header on
,and each part on the first=, to gettandv1. - Refuse the request if
tis not a whole number, or is more than 300 seconds away from your clock in either direction. - Compute the HMAC-SHA256 of
{t}.{raw body}with your secret, as lower-case hex. - Compare it with
v1in constant time. Refuse the request if they differ.
Use the raw body
Sign-check the exact bytes you received. If your framework has already parsed the JSON, re-encoding it will change spacing, key order or escaping, and a valid signature will fail. Every example below reads the raw body first.
Worked example
With the secret whsec_5f2b8c1e9a7d4036b1c8e2f94a6d0b3c7e1f5a9d2c4b6e80, t of 1792834200 and this exact body (one line, no trailing newline):
{"id":"01JB3K8Q2V7N4M6P9R1T3W5Y7Z","type":"contact.updated","created_at":"2026-10-24T09:30:00+00:00","store":"hearth-and-oak","api_version":"2026-10-01","data":{"object":"contact","id":12,"changed":["email","lifecycle_stage"],"actor":{"type":"api"}}}the signed string is 1792834200. followed by the body, and the expected v1 is:
99a914898dda783f7dc723607b20ad0e2e250a8f00e6af1f3aa7b52a8b0f74a9Use these values in a unit test of your receiver, with your clock fixed at 1792834200.
Node.js
A helper using only node:crypto. It accepts the raw body as a Buffer or a string.
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SECONDS = 300
export function verifyElaraOneSignature(
rawBody: Buffer | string,
header: string | null | undefined,
secret: string,
now: number = Math.floor(Date.now() / 1000),
): boolean {
if (!header) return false
let timestamp = ''
const signatures: string[] = []
for (const part of header.split(',')) {
const [name, value = ''] = part.trim().split('=', 2)
if (name === 't') timestamp = value
if (name === 'v1') signatures.push(value)
}
if (!/^\d+$/.test(timestamp)) return false
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false
const expected = Buffer.from(
createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex'),
'utf8',
)
return signatures.some((signature) => {
const given = Buffer.from(signature, 'utf8')
return given.length === expected.length && timingSafeEqual(given, expected)
})
}timingSafeEqual throws when the two buffers differ in length, which is why the length is checked first.
Express
Use express.raw() on the webhook route so req.body is the untouched Buffer. Register it before any global express.json(), or exclude this path from it.
import express from 'express'
import { verifyElaraOneSignature } from './lib/elaraone-webhooks'
const app = express()
app.post('/webhooks/elaraone', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.get('X-ElaraOne-Signature')
if (!verifyElaraOneSignature(req.body, signature, process.env.ELARAONE_WEBHOOK_SECRET!)) {
return res.status(400).send('Invalid signature')
}
const event = JSON.parse(req.body.toString('utf8'))
const deliveryId = req.get('X-ElaraOne-Delivery')
// Queue the work, then answer straight away.
queue.add('elaraone-webhook', { deliveryId, event })
res.status(200).json({ received: true })
})Next.js route handler
Read the body with await req.text() before anything parses it.
import { verifyElaraOneSignature } from '@/lib/elaraone-webhooks'
export async function POST(req: Request) {
const rawBody = await req.text()
const signature = req.headers.get('X-ElaraOne-Signature')
if (!verifyElaraOneSignature(rawBody, signature, process.env.ELARAONE_WEBHOOK_SECRET!)) {
return new Response('Invalid signature', { status: 400 })
}
const event = JSON.parse(rawBody)
const deliveryId = req.headers.get('X-ElaraOne-Delivery')
await enqueue({ deliveryId, event }) // do the slow work elsewhere
return Response.json({ received: true })
}PHP
<?php
function verify_elaraone_signature(string $rawBody, ?string $header, string $secret, ?int $now = null): bool
{
if ($header === null || $header === '') {
return false;
}
$timestamp = '';
$signatures = [];
foreach (explode(',', $header) as $part) {
[$name, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($name === 't') {
$timestamp = $value;
} elseif ($name === 'v1') {
$signatures[] = $value;
}
}
if (! ctype_digit($timestamp) || abs(($now ?? time()) - (int) $timestamp) > 300) {
return false;
}
$expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_ELARAONE_SIGNATURE'] ?? null;
if (! verify_elaraone_signature($rawBody, $header, getenv('ELARAONE_WEBHOOK_SECRET'))) {
http_response_code(400);
exit('Invalid signature');
}
$event = json_decode($rawBody, true);
http_response_code(200);hash_equals compares in constant time. Pass the expected value first.
Laravel
Put the route in routes/api.php, which has no CSRF check, and read the body with $request->getContent().
use App\Jobs\HandleElaraOneWebhook;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('webhooks/elaraone', function (Request $request) {
$rawBody = $request->getContent();
if (! verify_elaraone_signature($rawBody, $request->header('X-ElaraOne-Signature'), config('services.elaraone.webhook_secret'))) {
abort(400, 'Invalid signature');
}
HandleElaraOneWebhook::dispatch(
$request->header('X-ElaraOne-Delivery'),
json_decode($rawBody, true),
);
return response()->json(['received' => true]);
});When verification fails
- Every request fails. Check you are using the endpoint's own
whsec_secret, not the store's API secret, and that you used the raw body. If someone pressed New secret in the admin, the old secret stopped working at once. - Only some requests fail with a stale
t. Check your server's clock is synchronised (NTP). The tolerance is 300 seconds either way. - A proxy rewrites the body. Some proxies and middleware re-encode JSON or change line endings. Verify before any of them touch the request.
Answer a failed check with a 4xx. The delivery is then retried as described in Retries and delivery, which gives you time to fix a configuration mistake.
Flow webhook actions sign differently, with no timestamp and the store's server secret; see the webhook action.