Skip to main content

Webhook Signature Verification

Every inbound request includes an HMAC signature. Your server must verify the signature before processing and reject forged requests.

Signature headers​

HeaderValue
X-Bridge-Signaturev0=<hex> — the signature (lowercase hex, v0= prefix)
X-Bridge-TimestampTime the signature was generated (unix seconds)
X-Bridge-EventThe event kind
X-Bridge-Delivery-IdUnique 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-secret value.
  • Encoding: lowercase hex, v0= prefix
Use the raw body as received

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​

  1. Obtain the raw body as bytes.
  2. Read X-Bridge-Timestamp.
  3. Compute expected = "v0=" + hex(HMAC_SHA256(whsec, "v0:" + ts + ":" + body)).
  4. Compare with X-Bridge-Signature using a constant-time comparison (to prevent timing attacks).
  5. (Recommended) If ts deviates 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
});
Reference receiver

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.