Skip to content
Docs
Esc
↑↓navigate↵open⌘Jpreview
On this page

Verifying Webhooks

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

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.

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.

Compare

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

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

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);
});

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

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();
});
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