How to receive Turnado webhooks and verify the signature
Subscribe to events, check the signature in Node, Python or C#, and deduplicate on the idempotency key — because delivery is at least once, never exactly once.
A webhook is how Turnado tells another system that something moved: a status changed, a comment landed, a run finished. Everything below is enforced by tests that sign with one half of the module and verify with the other, so the examples here are not illustrative — they are the contract.
What arrives
POST /your/webhook HTTP/1.1
content-type: application/json
user-agent: Turnado-Webhook/1
x-turnado-topic: item.status_changed
x-turnado-idempotency-key: ev-8241:sub-3
x-turnado-attempt: 1
x-turnado-signature: t=1780000000,v1=5d41402abc4b2a76b9719d911017c5924e6f0d1c…
{
"id": "ev-8241:sub-3",
"topic": "item.status_changed",
"subjectId": "US-104",
"payload": { "from": "in_progress", "to": "code_review" },
"createdAt": "2026-08-07T12:00:00.000Z"
}x-turnado-topic— what it is about, identical totopicin the body.x-turnado-idempotency-key— stable across every attempt of the same event. Deduplicate on this.x-turnado-attempt— which attempt this is, counting from 1.x-turnado-signature— the signature, described below.
Answer with a 2xx. Anything else — including a 3xx, because redirects are not followed — counts as failed and comes back on the schedule 10 s, 1 min, 5 min, 30 min, 2 hours. After that the message goes to the dead letter box and waits until someone offers it again from the integrations screen.
The signature
The header is t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256(secret, "<t>.<raw body>") in lowercase hex. This is the shape Stripe uses, and the timestamp is the reason: without one, a captured request stays replayable forever. Turnado allows five minutes of clock difference.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(secret, rawBody, header, nowSeconds = Math.floor(Date.now() / 1000)) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)))
const t = Number(parts.t)
if (!Number.isFinite(t) || Math.abs(nowSeconds - t) > 300) return false
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
const a = Buffer.from(expected, 'utf8')
const b = Buffer.from(parts.v1 ?? '', 'utf8')
return a.length === b.length && timingSafeEqual(a, b)
}Compare in constant time. A plain === leaks, through timing, how many leading characters were right — which is enough to reconstruct a valid signature given patience.
A subscription without a secret is refused
You cannot create a subscription without a signing secret. That is not strictness for its own sake: an unsigned webhook is an open endpoint that anyone who guesses the URL can post to, and the system that receives it will believe every word. The secret is generated for you, carries the prefix tn_whsec_, and is shown once.
The other direction: your CI reports to us
Incoming reports from a pipeline use the CI token of the organisation rather than a signature, because a pipeline already holds a secret and adding a second one buys nothing. See Set up delivery for the endpoints and an example.
Will I ever receive the same event twice?
Yes. Delivery is at least once and never zero times, which means a duplicate is normal rather than a bug. Deduplicate on x-turnado-idempotency-key, which is stable across all attempts of the same event.
Why not the GitHub signature shape?
Because X-Hub-Signature-256 carries no timestamp, and without a timestamp a captured request can be replayed indefinitely. The Stripe shape carries one, and receivers that already speak it need no new code.
Can Turnado call an address inside my network?
No. The delivery agent refuses addresses inside its own network, does not follow redirects, and imposes a time limit — so a webhook cannot be turned into a way to probe infrastructure from the inside.