Skip to content

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.

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.

Terminal window
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.

POST /hooks/tkana HTTP/1.1
Content-Type: application/json
X-Tkana-Event-Id: 1b7c0c1e-1d3a-4c5e-9f77-6a1b2c3d4e5f
X-Tkana-Event-Type: ticket.created
X-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.

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.

  1. Read the raw body, before any JSON parsing. Re-serializing changes the bytes and the signature will never match.

  2. Reject anything whose t is more than five minutes from now, so a captured delivery cannot be replayed later.

  3. Compare using a constant-time function — crypto.timingSafeEqual, not === — and accept the delivery if any v1 matches. 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));
});
}
Terminal window
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.

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:

Terminal window
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.

The delivery log is the last 30 days of what we sent you, newest first:

Terminal window
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:

Terminal window
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.

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.