Webhooks
Webhooks tell your server the moment something happens in a KLCT account, so you do not have to poll. KLCT sends an HTTPS POST with a JSON body to a URL you register.
Webhook subscriptions belong to an OAuth connection, so they need the webhooks:manage scope. An account API key cannot create them.
Subscribe
curl -X POST "https://app.klctai.com/api/oauth-platform/webhooks" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"event_type":"ticket.created","target_url":"https://yourapp.example/hooks/klct"}'
The response includes a signing_secret. It is shown once. Store it to verify deliveries. The target_url must be HTTPS. Supported events:
contact.created, call.completed, ticket.created, conversation.message_received, changelog.published, feedback.created
You can list your subscriptions with GET /oauth-platform/webhooks and remove one with DELETE /oauth-platform/webhooks/{id}. Subscriptions end when the customer disconnects your app.
What a delivery looks like
POST https://yourapp.example/hooks/klct
Content-Type: application/json
X-KLCT-Event: ticket.created
X-KLCT-Signature: 7c1f...e90a
{
"event": "ticket.created",
"id": "9b6e3f0c-7a1d-4f4e-9d52-2c1f0a6d8e11",
"created_at": "2026-10-14T09:12:01+00:00",
"data": { "ticket_id": 1042, "account_id": 4821, "subject": "Cannot log in on mobile" }
}
id is unique per delivery. data depends on the event and is listed below.
Verify the signature
X-KLCT-Signature is the hex HMAC-SHA256 of the raw request body using your signing_secret. Compute it over the exact bytes you received, before parsing the JSON, and compare in constant time. Reject anything that does not match.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, header, secret) {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(header || '');
return a.length === b.length && timingSafeEqual(a, b);
}
$expected = hash_hmac('sha256', $request->getContent(), $signingSecret);
abort_unless(hash_equals($expected, (string) $request->header('X-KLCT-Signature')), 401);
Delivery behaviour
- Answer with any
2xxwithin 10 seconds. - If the connection fails (timeout, DNS, refused), KLCT retries up to three attempts in total, waiting about 10 seconds and then about a minute.
- A non-2xx answer is not retried; it is treated as your server's decision. Return
2xxand process asynchronously if you need more time. - Deliveries can repeat and can arrive out of order. De-duplicate on
id, and if order matters, fetch the current record with the API using the id indata. - Payloads are small on purpose. They carry ids and a few key fields; call the API for the rest.
- Customer-visible data only: internal notes and staff details are never sent.
Events
contact.created
A contact was added to the account, by any route.
{ "contact_id": 90412, "name": "Dana Cole", "email": "[email protected]", "phone": "+15551234567", "lead_status": "new" }
call.completed
An AI call finished.
{ "phone_call_id": 55120, "contact_id": 90412, "from": "+15559876543", "to": "+15551234567", "duration": 142, "ended_reason": "customer-ended-call" }
ticket.created
A support ticket was created, from email, chat, a form or the API.
{ "ticket_id": 1042, "account_id": 4821, "ticket_number": "SUP-1042", "subject": "Cannot log in on mobile", "status": "open", "priority": 2, "source": "email", "contact_id": 90412, "created_at": "2026-10-14T09:12:00+00:00" }
Fetch the full ticket with GET /tickets/{ticket_id} (scope tickets:read).
conversation.message_received
A customer wrote in a chat conversation. Replies from your team or the AI, and internal notes, do not fire this.
{ "conversation_id": 7781, "message_id": 20933, "account_id": 4821, "contact_id": 90412, "channel": "web", "sender_type": "visitor", "body": "Do you open on Sundays?", "created_at": "2026-10-14T09:18:00+00:00" }
body is cut to 2000 characters. Fetch the thread with GET /conversations/{conversation_id} (scope conversations:read) and answer with POST /conversations/{id}/messages.
changelog.published
A changelog entry went live: created as published, or moved from draft to published. Later edits do not fire it again.
{ "entry_id": 214, "account_id": 4821, "title": "Faster ticket search", "version": "v2.4.0", "slug": "faster-ticket-search", "published_at": "2026-10-14T12:00:00+00:00" }
feedback.created
A customer posted on the feedback board. The author's name and email are not included.
{ "post_id": 88, "account_id": 4821, "title": "Dark mode", "status": "under_review", "category_id": 2, "created_at": "2026-10-14T08:30:00+00:00" }