Webhooks
Register an HTTPS endpoint, choose the events you care about, and tkana posts to it when something changes — no polling, and no delay while you wait for the next pull.
The events
Section titled “The events”| Event | Sent when | data |
|---|---|---|
customer.created / .updated |
a contact is added or changed | the customer |
customer.deleted |
a contact is deleted | a removal record |
conversation.created |
a thread starts | the conversation |
conversation.assigned |
it is given to a teammate | the conversation |
conversation.escalated |
it is handed to a person | the conversation |
conversation.resolved |
it is closed | the conversation, summary still null |
conversation.summarized |
the AI summary is written, minutes later | the conversation, summary filled |
message.received / .sent |
a message arrives or goes out | the message |
ticket.created / .updated / .status_changed / .assigned |
a ticket changes | the ticket |
ticket.deleted |
a ticket is deleted | a removal record |
booking.created / .confirmed / .rescheduled / .cancelled / .completed / .no_show / .rejected |
a booking changes | the booking |
data is the object’s public representation — byte for byte what the matching GET
returns, read at the moment of delivery. So a ticket.status_changed carries the whole
ticket, not a diff, and you need no second schema for the webhook.
The two *.deleted events are the exception, and necessarily so: the subject is gone,
and there is nothing left to read. They carry the same removal record a sync reports,
with the same removedAt, so the two can be reconciled against each other:
{ "object": "removed", "id": "8f14e45f-ceea-4c1a-9b31-9f3ba3a5d9c1", "resource": "ticket", "removedAt": "2026-05-04T09:12:33.019Z"}Two consequences worth knowing. Because the object is read at delivery time, a change that happens while a delivery is being retried is reflected in what finally arrives — the payload is the object’s state, not a snapshot of the instant it changed. And because delivery uses the same rules the API does, an object the API would not serve you is not delivered either.
Registering
Section titled “Registering”curl -s -X POST "$TKANA_API/webhooks" \ -H "Authorization: Bearer $TKANA_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/tkana", "description": "Helpdesk sync", "events": ["ticket.created", "ticket.status_changed", "conversation.resolved"] }'The response carries a signing secret, shown once. Store it before you close the window; no endpoint returns it again, and the only way to get a working one back is to rotate.
The URL must be https and must resolve to a public address. A private or loopback
address is refused with 422 — at registration, and again at delivery time, because a
hostname’s answer can change after you register it.
What a delivery looks like
Section titled “What a delivery looks like”POST /hooks/tkana HTTP/1.1Content-Type: application/jsonX-Tkana-Event-Id: 1b7c0c1e-1d3a-4c5e-9f77-6a1b2c3d4e5fX-Tkana-Event-Type: ticket.createdX-Tkana-Signature: t=1793200000,v1=9f86d081884c7d65...{ "id": "1b7c0c1e-1d3a-4c5e-9f77-6a1b2c3d4e5f", "type": "ticket.created", "apiVersion": "v1", "createdAt": "2026-09-22T10:00:00.000Z", "organizationId": "8f14e45f-ce0a-4a1b-9c2d-3e4f5a6b7c8d", "data": {}}data is the object’s public representation — the same shape the matching GET returns —
so you need no second schema for the webhook. A ping carries an empty data.
Verifying a delivery
Section titled “Verifying a delivery”X-Tkana-Signature carries the timestamp and one or more signatures, comma separated:
t=<unix seconds>,v1=<hex>v1 is HMAC-SHA256 over <t>.<raw body>, using your endpoint’s secret. The timestamp is
part of what is signed, so it cannot be rewritten by someone replaying a delivery they
captured.
-
Read the raw body, before any JSON parsing. Re-serializing changes the bytes and the signature will never match.
-
Reject anything whose
tis more than five minutes from now, so a captured delivery cannot be replayed later. -
Compare using a constant-time function —
crypto.timingSafeEqual, not===— and accept the delivery if anyv1matches. There is more than one during a secret rotation.
import crypto from "node:crypto";
export function verify(rawBody, signatureHeader, secret) { const parts = (signatureHeader ?? "").split(",").map((p) => p.trim()); const timestamp = Number(parts.find((p) => p.startsWith("t="))?.slice(2)); if (!Number.isFinite(timestamp)) return false;
// Replay window: five minutes, in both directions. if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = crypto .createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`) .digest("hex");
return parts .filter((p) => p.startsWith("v1=")) .some((p) => { const candidate = p.slice(3); // timingSafeEqual throws on a length mismatch, so check that first. if (candidate.length !== expected.length) return false; return crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(expected)); });}Rotating the secret
Section titled “Rotating the secret”curl -s -X POST "$TKANA_API/webhooks/$ID/rotate-secret" \ -H "Authorization: Bearer $TKANA_KEY"You get a new secret once. For the next 24 hours every delivery is signed with both
the new secret and the old one, and the header carries a v1 for each — so a receiver
that checks every v1 keeps working while you deploy the new value, with no gap and no
dropped delivery. After the window, only the new secret signs.
Answering
Section titled “Answering”Reply with any 2xx within 10 seconds — ideally after nothing more than queueing the
work. Doing the processing inline turns a slow database into a failed delivery.
Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 8 hours. A redirect is not a success: we do not follow one.
An endpoint that has been failing continuously for 72 hours is disabled, and stops receiving. One case disables it immediately instead: if its URL stops resolving to a public address — a hostname repointed at a private or loopback address after you registered it — delivery stops at once rather than being retried. Re-enable it once your receiver is healthy:
curl -s -X PATCH "$TKANA_API/webhooks/$ID" \ -H "Authorization: Bearer $TKANA_KEY" \ -H "Content-Type: application/json" \ -d '{"status": "active"}'That also resets the failure count, so one hiccup afterwards will not switch it off again.
Debugging a receiver that was down
Section titled “Debugging a receiver that was down”The delivery log is the last 30 days of what we sent you, newest first:
curl -s "$TKANA_API/webhooks/$ID/deliveries?status=failed" \ -H "Authorization: Bearer $TKANA_KEY"Filter by status (pending, succeeded, failed) and by eventType. Each row carries
the response status we got, when the next attempt is due, and when it was delivered — but
never the payload we sent, because that embeds your customers’ details and the log is
about what happened to an event, not about repeating its contents.
Send a failed one again once your receiver is healthy:
curl -s -X POST "$TKANA_API/webhooks/$ID/deliveries/$DELIVERY_ID/retry" \ -H "Authorization: Bearer $TKANA_KEY"You get 202 and the delivery back in pending, and it is sent with the payload it was
created with — not a fresh read — so a retry weeks later still describes what happened
then. Retrying something that already succeeded is 409: we will not send you one event
twice on purpose. A delivery that is still pending is 409 too, because an attempt is
already coming.
Duplicates are normal
Section titled “Duplicates are normal”A retry can arrive after your handler already succeeded but before the response got back.
Treat every handler as idempotent: X-Tkana-Event-Id is the same value on every attempt
at one event, and skipping an id you have already processed is the simplest way to be
safe. Ordering is not guaranteed either — do not assume ticket.created arrives before
the ticket.status_changed that follows it.