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