Webhook Signature Verification
Every inbound request includes an HMAC signature. Your server must verify the signature before processing and reject forged requests.
Signature headers
| Header | Value |
|---|---|
X-Bridge-Signature | v0=<hex> — the signature (lowercase hex, v0= prefix) |
X-Bridge-Timestamp | Time the signature was generated (unix seconds) |
X-Bridge-Event | The event kind |
X-Bridge-Delivery-Id | Unique delivery ID (UUID, identical across retries) |
Signature algorithm
signing_input = "v0:" + X-Bridge-Timestamp + ":" + <raw request body string>
signature = "v0=" + lowerhex( HMAC_SHA256( whsec, signing_input ) )
- Hash: HMAC-SHA256
- Key: the entire signature verification key string
whsec_...(UTF-8 bytes). For the hosted Gateway it is shown in TalkBridge Center (derived from the connected Agent Key); for the CLI Gateway it is the--webhook-secretvalue. - Encoding: lowercase hex,
v0=prefix
The signature is computed over the raw bytes you received (raw body). If you parse the JSON and re-serialize it, whitespace and key order change and the signature no longer matches. Verify the body exactly as read.
Verification procedure
- Obtain the raw body as bytes.
- Read
X-Bridge-Timestamp. - Compute
expected = "v0=" + hex(HMAC_SHA256(whsec, "v0:" + ts + ":" + body)). - Compare with
X-Bridge-Signatureusing a constant-time comparison (to prevent timing attacks). - (Recommended) If
tsdeviates significantly from the current time, treat it as a replay and reject it.
Reference implementations
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
const WHSEC = process.env.WHSEC; // "whsec_..."
// Use the raw parser to obtain the raw body as-is.
app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => {
const ts = req.get('X-Bridge-Timestamp') || '';
const sig = req.get('X-Bridge-Signature') || '';
const body = req.body; // Buffer (raw)
const mac = crypto.createHmac('sha256', WHSEC)
.update('v0:' + ts + ':').update(body)
.digest('hex');
const expected = 'v0=' + mac;
const ok = sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok) return res.status(401).send('bad signature');
const evt = JSON.parse(body.toString('utf8'));
// evt = { brand, userKey, kind, seq }
// ... processing ...
res.sendStatus(200); // must be 2xx
});
C# (ASP.NET minimal API)
app.MapPost("/webhook", async (HttpRequest req) =>
{
using var ms = new MemoryStream();
await req.Body.CopyToAsync(ms);
var body = ms.ToArray(); // raw bytes
var ts = req.Headers["X-Bridge-Timestamp"].ToString();
var sig = req.Headers["X-Bridge-Signature"].ToString();
var key = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("WHSEC")!);
var msg = Encoding.UTF8.GetBytes($"v0:{ts}:").Concat(body).ToArray();
var expected = "v0=" + Convert.ToHexString(HMACSHA256.HashData(key, msg)).ToLowerInvariant();
var ok = CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(sig), Encoding.UTF8.GetBytes(expected));
if (!ok) return Results.Unauthorized();
// JsonSerializer.Deserialize(body) → { brand, userKey, kind, seq }
return Results.Ok(); // must be 2xx
});
project/webconsult-test in the TalkBridge repository is the reference implementation of this verification scheme —
a complete flow spanning receive → verify signature → fetch content → consultation screen → send,
and it actually implements idempotency (X-Bridge-Delivery-Id), immediate 2xx followed by asynchronous post-processing, and the kind:"agent" echo filter.
The signature verdict is pinned as a contract by fixed-vector tests in project/webconsult-test.tests.