Skip to content
Elara One docs

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=99a914898dda783f7dc723607b20ad0e2e250a8f00e6af1f3aa7b52a8b0f74a9
  • t is the Unix time, in seconds, when this attempt was signed. Each retry is signed afresh.
  • v1 is the lower-case hex HMAC-SHA256 of the string t + . + 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:

  1. Split the header on , and each part on the first =, to get t and v1.
  2. Refuse the request if t is not a whole number, or is more than 300 seconds away from your clock in either direction.
  3. Compute the HMAC-SHA256 of {t}.{raw body} with your secret, as lower-case hex.
  4. Compare it with v1 in 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:

99a914898dda783f7dc723607b20ad0e2e250a8f00e6af1f3aa7b52a8b0f74a9

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

lib/elaraone-webhooks.ts
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.

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

app/api/webhooks/elaraone/route.ts
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

webhook.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().

routes/api.php
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.