Signature Verification
Verify Talonic webhook signatures with HMAC-SHA256 over the raw request body. Step-by-step guide with Python, Node.js, and openssl examples plus test vectors.
Webhook signature verification proves that an incoming webhook was sent by Talonic and not by an attacker who discovered your endpoint URL. Talonic signs every webhook delivery with HMAC-SHA256: it computes a keyed hash of the raw JSON request body using your webhook signing secret and sends the hex digest in the X-Talonic-Signature header as sha256=<hex>. Your server recomputes the same hash and compares the two values in constant time.
Webhooks are delivered as POST requests with a JSON body. Each delivery includes these headers:
X-Talonic-Event— The event type (e.g. extraction.complete).X-Talonic-Signature— HMAC-SHA256 signature of the raw request body, formattedsha256=<hex>. Sent only when a signing secret is configured on the webhook.X-Talonic-Delivery— Unique delivery ID (e.g.dlv_a1b2c3d4e5f67890) for idempotency. Stable across retries of the same delivery.Content-Type— Alwaysapplication/json.User-Agent— AlwaysTalonic-Webhooks/1.0.
Webhook payload example
{
"event": "extraction.complete",
"delivery_id": "dlv_a1b2c3d4e5f67890",
"timestamp": "2026-01-15T10:00:00.000Z",
"data": {
"document_id": "3d44a4dc-e3e4-4bca-b079-a9c85bf75026",
"extraction_id": "3d44a4dc-e3e4-4bca-b079-a9c85bf75026",
"filename": "invoice.pdf",
"field_count": 12,
"confidence_overall": 0.92
}
}Verification steps
- Read the raw request body as bytes or a UTF-8 string. Do not parse and re-serialize the JSON first: key reordering or whitespace changes will break the signature.
- Compute HMAC-SHA256 of the raw body using your webhook signing secret as the key.
- Hex-encode the digest.
- Compare it with the value after
sha256=in theX-Talonic-Signatureheader. - Use a constant-time comparison (
hmac.compare_digest,crypto.timingSafeEqual) to prevent timing attacks.
Python
import hmac
import hashlib
def verify_webhook(payload: bytes, signature_header: str, secret: str) -> bool:
"""payload must be the raw request body bytes, not re-serialized JSON."""
if not signature_header or not signature_header.startswith("sha256="):
return False
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest("sha256=" + expected, signature_header)Node.js (with Express raw-body capture)
const crypto = require('crypto');
const express = require('express');
// payload must be the RAW request body (Buffer or string)
function verifyWebhook(payload, signatureHeader, secret) {
if (!signatureHeader || !signatureHeader.startsWith('sha256=')) return false;
const expected = crypto.createHmac('sha256', secret).update(payload).digest();
const received = Buffer.from(signatureHeader.slice('sha256='.length), 'hex');
// Length check first: timingSafeEqual throws on unequal lengths
return expected.length === received.length
&& crypto.timingSafeEqual(expected, received);
}
const app = express();
// express.raw() preserves the exact bytes Talonic signed
app.post('/webhooks/talonic', express.raw({ type: 'application/json' }), (req, res) => {
const valid = verifyWebhook(
req.body,
req.headers['x-talonic-signature'],
process.env.TALONIC_WEBHOOK_SECRET
);
if (!valid) return res.status(401).send('invalid signature');
const event = JSON.parse(req.body); // parse only AFTER verification
console.log(event.event, event.delivery_id);
res.status(200).send('ok');
});express.json() parses the body before your handler runs, and JSON.stringify(req.body) rarely reproduces the original byte sequence. Capture the raw body (express.raw(), Flask request.get_data(), Django request.body) and hash that.Test vectors
Use these values to verify your signature implementation is correct before pointing Talonic at it:
Test inputs
Secret: whsec_test_secret_do_not_use_in_production
Payload: {"event":"extraction.complete","delivery_id":"dlv_a1b2c3d4e5f67890","timestamp":"2026-01-15T10:00:00.000Z","data":{"document_id":"3d44a4dc-e3e4-4bca-b079-a9c85bf75026","extraction_id":"3d44a4dc-e3e4-4bca-b079-a9c85bf75026","filename":"invoice.pdf","field_count":12,"confidence_overall":0.92}}
Expected signature: sha256=9832313c4adcbe269b8b7c11c1855f6d437f2089a15aa22ebd9f21d356d062edVerify with the command line
# Compute expected signature
printf '%s' '{"event":"extraction.complete","delivery_id":"dlv_a1b2c3d4e5f67890","timestamp":"2026-01-15T10:00:00.000Z","data":{"document_id":"3d44a4dc-e3e4-4bca-b079-a9c85bf75026","extraction_id":"3d44a4dc-e3e4-4bca-b079-a9c85bf75026","filename":"invoice.pdf","field_count":12,"confidence_overall":0.92}}' \
| openssl dgst -sha256 -hmac "whsec_test_secret_do_not_use_in_production"
# Output: 9832313c4adcbe269b8b7c11c1855f6d437f2089a15aa22ebd9f21d356d062edsecret field when you [create the webhook configuration](create-webhook-config).