# 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](oauth), so they need the `webhooks:manage` scope. An account API key cannot create them.

## Subscribe

```bash
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
```

```json
{
  "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.

```js
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);
}
```

```php
$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.

```json
{ "contact_id": 90412, "name": "Dana Cole", "email": "dana@acme.example", "phone": "+15551234567", "lead_status": "new" }
```

### call.completed

An AI call finished.

```json
{ "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.

```json
{ "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.

```json
{ "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.

```json
{ "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.

```json
{ "post_id": 88, "account_id": 4821, "title": "Dark mode", "status": "under_review", "category_id": 2, "created_at": "2026-10-14T08:30:00+00:00" }
```
