Skip to content

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.

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:

  1. Split the header on commas into t and one or more v1 values.
  2. Check t is within five minutes of now, so an old delivery can’t be replayed.
  3. Compute the HMAC-SHA256 of t + "." + body with your secret, using the raw body exactly as received (before any JSON parsing), as lowercase hex.
  4. Compare it with each v1 in 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)