Webhooks
Add an endpoint in Settings > Developers (or with the API’s webhook endpoints) and choose the
events to receive. We POST JSON to your https URL:
{ "id": "6f1c...", "event": "lead.created", "createdAt": "2026-10-04T09:30:00.000Z", "organisationId": "0b7e...", "actor": { "type": "user", "id": "usr_8f2c" }, "data": { ... }}actor says who caused the event: user, api_key, connection, flow, assistant
or system. When an installed app’s request caused it, the actor is that installation’s key and
carries its installationId, so an app can skip the echoes of its own changes.
Each endpoint’s delivery log (Settings > Developers, or the API) keeps every delivery for 7 days with every attempt (time taken, response code, what went wrong), and its health over 7, 30 or 90 days. Replay one delivery, or every one that gave up in a time range after an outage at your end.
Answer with any 2xx within 10 seconds. Anything else is retried with backoff for 24 hours;
an endpoint that accepts nothing for 24 hours is disabled until you resume it. The delivery id is
also sent as Threadkite-Delivery and Idempotency-Key, so you can ignore repeats.
Checking the signature
Section titled “Checking the signature”Every delivery has a Threadkite-Signature header: t=<unix seconds>,v1=<hex>, an
HMAC-SHA256 of <t>.<raw body> with your endpoint’s signing secret. After you roll a secret,
deliveries carry a v1 for both the old and new secret for 24 hours. To check it:
- Split the header on commas into
tand one or morev1values. - Check
tis within five minutes of now, so an old delivery can’t be replayed. - Compute the HMAC-SHA256 of
t + "." + bodywith your secret, using the raw body exactly as received (before any JSON parsing), as lowercase hex. - Compare it with each
v1in constant time. One match is enough.
With Node.js (no packages needed):
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyThreadkiteSignature(secret, header, rawBody, toleranceSeconds = 300) { if (!secret || !header || !Buffer.isBuffer(rawBody)) return false; let timestamp = null; const signatures = []; for (const part of header.split(',')) { const [key, value] = part.trim().split('='); if (key === 't' && /^[0-9]{1,12}$/.test(value)) timestamp = Number(value); if (key === 'v1' && /^[0-9a-f]{64}$/i.test(value)) signatures.push(value.toLowerCase()); } if (timestamp === null || signatures.length === 0) return false; if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false; const expected = createHmac('sha256', secret) .update(`${timestamp}.`) .update(rawBody) .digest(); return signatures.some((signature) => timingSafeEqual(expected, Buffer.from(signature, 'hex')));}
// Express: read the body raw, check it, then parse it.app.post('/hooks/threadkite', express.raw({ type: 'application/json' }), (req, res) => { const ok = verifyThreadkiteSignature( process.env.THREADKITE_WEBHOOK_SECRET, req.get('threadkite-signature'), req.body, ); if (!ok) return res.status(400).end(); const delivery = JSON.parse(req.body.toString('utf8')); // A delivery can arrive more than once: skip ids you've already handled, then handle // delivery.event. res.status(200).end();});With Python:
import hashlib, hmac, re, time
def verify_threadkite_signature(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool: timestamp, signatures = None, [] for part in (header or "").split(","): key, _, value = part.strip().partition("=") if key == "t" and re.fullmatch(r"[0-9]{1,12}", value): timestamp = int(value) elif key == "v1" and re.fullmatch(r"[0-9a-fA-F]{64}", value): signatures.append(value.lower()) if not secret or not isinstance(raw_body, bytes) or timestamp is None or not signatures: return False if abs(time.time() - timestamp) > tolerance: return False expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, signature) for signature in signatures)