Skip to main content

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, formatted sha256=<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 — Always application/json.
  • User-Agent — Always Talonic-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

  1. 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.
  2. Compute HMAC-SHA256 of the raw body using your webhook signing secret as the key.
  3. Hex-encode the digest.
  4. Compare it with the value after sha256= in the X-Talonic-Signature header.
  5. 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');
});
The most common verification failure is hashing a re-serialized body instead of the raw bytes. Middleware like 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=9832313c4adcbe269b8b7c11c1855f6d437f2089a15aa22ebd9f21d356d062ed

Verify 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: 9832313c4adcbe269b8b7c11c1855f6d437f2089a15aa22ebd9f21d356d062ed
The test secret above is for development only. Never use it in production. Set your real signing secret via the secret field when you [create the webhook configuration](create-webhook-config).

Frequently asked questions

How do I verify Talonic webhook signatures?+
Compute an HMAC-SHA256 of the raw request body using your webhook signing secret, hex-encode the digest, and compare it against the value after "sha256=" in the X-Talonic-Signature header using a constant-time comparison function.
Why does my computed webhook signature not match?+
Almost always because you hashed a re-serialized body instead of the raw bytes. Parse-then-stringify changes whitespace and key order, which changes the hash. Capture the raw request body with middleware like express.raw() and hash exactly those bytes.
What happens if there is no X-Talonic-Signature header?+
The header is only sent when a signing secret is configured on the webhook. If you created the webhook without a secret, deliveries arrive unsigned. Set a secret via PATCH /v1/webhooks/:id and reject unsigned requests in production.
Why should I use a constant-time comparison for signatures?+
A naive string comparison returns early at the first mismatched character, so response timing leaks how many leading characters were correct. Constant-time functions like hmac.compare_digest or crypto.timingSafeEqual take the same time regardless of where the mismatch is.