# Verify signatures

URL: /docs/webhooks/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 [#the-signature]

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

<Callout type="warn" title="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.
</Callout>

## Worked example [#worked-example]

With the secret `whsec_5f2b8c1e9a7d4036b1c8e2f94a6d0b3c7e1f5a9d2c4b6e80`, `t` of `1792834200` and this exact body (one line, no trailing newline):

```json
{"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:

```text
99a914898dda783f7dc723607b20ad0e2e250a8f00e6af1f3aa7b52a8b0f74a9
```

Use these values in a unit test of your receiver, with your clock fixed at `1792834200`.

## Node.js [#nodejs]

A helper using only `node:crypto`. It accepts the raw body as a `Buffer` or a string.

```ts title="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 [#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.

```ts title="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 [#nextjs-route-handler]

Read the body with `await req.text()` before anything parses it.

```ts title="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 [#php]

```php title="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 [#laravel]

Put the route in `routes/api.php`, which has no CSRF check, and read the body with `$request->getContent()`.

```php title="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 [#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](/docs/webhooks/retries), 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](/docs/guides/flows#the-webhook-action).
