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
| Header | Example | Purpose |
|---|---|---|
TNL-Webhook-Id | dlv_KS-WuPchZ9as1XVVts9DYu | Delivery id, stable across retries — deduplicate on this |
TNL-Webhook-Timestamp | 1785409511 | Unix seconds, part of the signed string |
TNL-Webhook-Key-Id | key_UI2NWEcNW1ckYw | Which signing key was used |
TNL-Webhook-Signature | 9a1f… | Hex HMAC-SHA256 |
TNL-Webhook-Attempt-Id | att_3 | Distinguishes retries of one delivery |
TNL-Event-Type | intelligence.published | Routing without parsing the body |
TNL-Event-Version | 1.0 | Envelope 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.
npm install @theneuralledger/eventsimport 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
pip install tnl-intelligencefrom 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 "", 204request.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
2xxquickly and process asynchronously - Never log the secret, or the
Authorizationheader used to create it