KLCTDevelopers

Webhooks

View as Markdown

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 2xx within 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 2xx and 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 in data.
  • 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" }