---
title: "Verifying Webhooks"
description: "Check that webhook requests really come from Chatvox using the signing secret, with examples for Node.js, PHP, and Python."
---

Every webhook request is signed with your webhook's signing secret. Check the signature before trusting a request, so nobody else can send fake events to your endpoint.

Chatvox follows the [Standard Webhooks](https://www.standardwebhooks.com/) specification, so its libraries for many languages work too.

## Headers

| Header | Example | Description |
| --- | --- | --- |
| `webhook-id` | `4b1b9e0e-…` | The event's unique ID. Same as the payload `id`. |
| `webhook-timestamp` | `1791575812` | When the request was sent, in Unix seconds. |
| `webhook-signature` | `v1,K5oZfz…` | The signature, prefixed with the version `v1,`. |

## How to verify

1. **Build the signed content**

    Join the `webhook-id` header, the `webhook-timestamp` header, and the **raw request body**, separated by dots:

    ```
    {webhook-id}.{webhook-timestamp}.{raw body}
    ```

    Use the body exactly as received. Parsing and re-encoding the JSON can change it and break the signature.

2. **Compute the signature**

    Remove the `whsec_` prefix from your signing secret and Base64-decode the rest. Use the result as the key for an HMAC-SHA256 of the signed content, then Base64-encode the result.

3. **Compare**

    Check that `webhook-signature` contains `v1,` followed by your result. Use a constant-time comparison.

4. **Check the timestamp**

    Reject requests whose timestamp is more than 5 minutes from your server's clock. This stops someone from replaying an old request.

## Examples

**Node.js (Express)**

```js
import crypto from "node:crypto";
import express from "express";

const app = express();

function isValidChatvoxRequest(req) {
  const id = req.header("webhook-id");
  const timestamp = req.header("webhook-timestamp");
  const signatures = (req.header("webhook-signature") ?? "").split(" ");

  if (!id || !timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return false;
  }

  const key = Buffer.from(process.env.CHATVOX_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");
  const expected = Buffer.from(
    "v1," + crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${req.body}`).digest("base64"),
  );

  return signatures.some((signature) => {
    const received = Buffer.from(signature);
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

// express.raw keeps the body exactly as Chatvox sent it.
app.post("/webhooks/chatvox", express.raw({ type: "application/json" }), (req, res) => {
  if (!isValidChatvoxRequest(req)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body);

  if (event.type === "lead.created") {
    // Save event.data.lead to your CRM...
  }

  res.sendStatus(204);
});
```

**PHP (Laravel)**

Add the route to `routes/api.php`, which doesn't check CSRF tokens.

```php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;

Route::post('/webhooks/chatvox', function (Request $request) {
    $id = (string) $request->header('webhook-id');
    $timestamp = (string) $request->header('webhook-timestamp');

    $key = base64_decode(Str::after(env('CHATVOX_WEBHOOK_SECRET'), 'whsec_'));
    $expected = 'v1,'.base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$request->getContent()}", $key, true));

    $valid = collect(explode(' ', (string) $request->header('webhook-signature')))
        ->contains(fn (string $signature) => hash_equals($expected, $signature));

    abort_unless($valid && abs(time() - (int) $timestamp) <= 300, 401);

    $event = $request->json()->all();

    if ($event['type'] === 'lead.created') {
        // Save $event['data']['lead'] to your CRM...
    }

    return response()->noContent();
});
```

**Python (Flask)**

```python
import base64
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)


def is_valid_chatvox_request() -> bool:
    msg_id = request.headers.get("webhook-id", "")
    timestamp = request.headers.get("webhook-timestamp", "0")
    signatures = request.headers.get("webhook-signature", "").split(" ")

    if abs(time.time() - int(timestamp)) > 300:
        return False

    key = base64.b64decode(os.environ["CHATVOX_WEBHOOK_SECRET"].removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + request.get_data()
    expected = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

    return any(hmac.compare_digest(expected, signature) for signature in signatures)


@app.post("/webhooks/chatvox")
def chatvox_webhook():
    if not is_valid_chatvox_request():
        abort(401)

    event = request.get_json()

    if event["type"] == "lead.created":
        pass  # Save event["data"]["lead"] to your CRM...

    return "", 204
```

:::tip
Respond with `2xx` as soon as you've verified and stored the event, then do the rest in the background. Requests that take longer than 10 seconds are retried.
:::
