Skip to content

Verifying deliveries

Every delivery is signed. Verify before you trust a payload — the endpoint is public, so anyone who learns the URL can post to it.

Headers

HeaderExamplePurpose
TNL-Webhook-Iddlv_KS-WuPchZ9as1XVVts9DYuDelivery id, stable across retries — deduplicate on this
TNL-Webhook-Timestamp1785409511Unix seconds, part of the signed string
TNL-Webhook-Key-Idkey_UI2NWEcNW1ckYwWhich signing key was used
TNL-Webhook-Signature9a1f…Hex HMAC-SHA256
TNL-Webhook-Attempt-Idatt_3Distinguishes retries of one delivery
TNL-Event-Typeintelligence.publishedRouting without parsing the body
TNL-Event-Version1.0Envelope schema version

The signature

signature = HMAC-SHA256(secret, "v1.{timestamp}.{deliveryId}." + rawBody)

Hex encoded. Note the trailing dot after the delivery id.

Sign the raw bytes

Compute over the exact body received, before any JSON parsing. Parsing and re-serialising changes whitespace and key order, and the signature will not match. Most frameworks need explicit configuration to expose the raw body — express.raw({ type: 'application/json' }) rather than express.json().

TypeScript

verifyWebhook from @theneuralledger/events does the whole check — signature, timestamp freshness, header shape, and optional replay detection.

bash
npm install @theneuralledger/events
ts
import express from 'express';
import { verifyWebhook } from '@theneuralledger/events';

const app = express();
const keys = { [process.env.TNL_WEBHOOK_KEY_ID!]: process.env.TNL_WEBHOOK_SECRET! };

app.post(
  '/hooks/tnl',
  express.raw({ type: 'application/json' }), // raw bytes, not parsed JSON
  async (req, res) => {
    try {
      await verifyWebhook({ rawBody: req.body, headers: req.headers, keys });
    } catch {
      return res.sendStatus(401);
    }

    // Acknowledge first, work afterwards: a slow handler gets retried.
    res.sendStatus(204);

    const event = JSON.parse(req.body.toString('utf8'));
    void handle(event);
  },
);

Pass every key you currently hold. During a rotation you will hold two, and verifyWebhook selects by TNL-Webhook-Key-Id.

Options: toleranceSeconds (default 300) bounds clock skew, and replayStore rejects a delivery id you have already accepted.

Python

bash
pip install tnl-intelligence
python
from flask import Flask, request
from tnl_intelligence import verify_webhook, WebhookVerificationError

app = Flask(__name__)
KEYS = {os.environ["TNL_WEBHOOK_KEY_ID"]: os.environ["TNL_WEBHOOK_SECRET"].encode()}

@app.post("/hooks/tnl")
def receive():
    try:
        verify_webhook(request.get_data(), request.headers, KEYS)
    except WebhookVerificationError:
        return "", 401

    event = request.get_json()
    enqueue(event)          # hand off; do not process inline
    return "", 204

request.get_data() returns raw bytes. request.get_json() would reparse and break the signature, so verify first.

Any other language

1. Read the raw body as bytes.
2. Read TNL-Webhook-Timestamp; reject if more than 300s from now.
3. Look up the secret for TNL-Webhook-Key-Id; reject if unknown.
4. base = "v1." + timestamp + "." + deliveryId + "."
5. expected = hex(HMAC_SHA256(secret, base + rawBody))
6. Compare with TNL-Webhook-Signature using a constant-time comparison.
7. Reject if TNL-Webhook-Id was already accepted.

Step 6 matters: == on strings leaks timing information. Use crypto.timingSafeEqual, hmac.compare_digest, or your language's equivalent.

Step 2 matters too — without it, a captured delivery can be replayed forever.

Checklist

  • Verify before parsing, and before any side effect
  • Compare in constant time
  • Enforce the timestamp window
  • Deduplicate on TNL-Webhook-Id
  • Accept all currently valid key ids
  • Return 2xx quickly and process asynchronously
  • Never log the secret, or the Authorization header used to create it

The Neural Ledger API