> ## Documentation Index
> Fetch the complete documentation index at: https://docs.keloa.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive signed POSTs from Keloa when conversation lifecycle events happen.

Webhooks let Keloa push events to your stack instead of you polling. You register an HTTPS endpoint and pick the events you care about; Keloa POSTs a signed JSON payload to that endpoint each time one of those events fires.

<Info>
  Webhooks are available on the **Business** and **Scale** plans. Business workspaces can register up to **10** endpoints, Scale up to **30**. Starter and Growth plans can't create webhook endpoints.
</Info>

Manage endpoints in the app: sidebar → **Settings → Integrations → the "Webhooks" tile**.

## Create an endpoint

<Steps>
  <Step title="Open the Webhooks tile">
    In Keloa, sidebar → **Settings → Integrations → Webhooks → New endpoint**.
  </Step>

  <Step title="Fill in the endpoint">
    * **Label** — a name so it's easy to identify (`prod-orders-pipeline`, `slack-relay`, ...).
    * **URL** — the `https://` URL that should receive POSTs. Plain HTTP is rejected.
    * **Events** — pick the [events](#events) you want this endpoint to receive.
  </Step>

  <Step title="Copy the signing secret">
    On creation Keloa shows a **signing secret** — format `whsec_` followed by 40 random characters. It is shown **exactly once**. Copy it into your receiver immediately; you'll need it to [verify each request](#verifying-the-signature).
  </Step>
</Steps>

<Note>
  Lost the secret? You can **reveal** it again or **rotate** it later from the endpoint's actions menu. Rotating invalidates the old secret — update your receiver before deliveries start failing.
</Note>

## Events

Keloa delivers five conversation lifecycle events:

| Event                    | Fires when                                                         |
| ------------------------ | ------------------------------------------------------------------ |
| `conversation.opened`    | A new conversation is created.                                     |
| `conversation.solved`    | A conversation is marked solved.                                   |
| `conversation.closed`    | A conversation is closed.                                          |
| `conversation.escalated` | A conversation is escalated to a human. Carries escalation fields. |
| `conversation.rated`     | A customer submits a CSAT rating. Carries CSAT fields.             |

## The payload

Every delivery is a JSON object with a stable top-level envelope (`id`, `type`, `created_at`, `account_id`, `data`). The `data` object holds the `conversation` the event is about.

```json theme={null}
{
  "id": "<event uuid>",
  "type": "conversation.solved",
  "created_at": "2026-05-16T10:30:00Z",
  "account_id": "<uuid>",
  "data": {
    "conversation": {
      "id": "<uuid>",
      "status": "solved",
      "channel": "webchat",
      "topic": "Refund request",
      "priority": "normal",
      "contact": { "id": "<uuid>", "name": "Jane Doe" },
      "assignee_id": "<uuid|null>",
      "created_at": "...",
      "resolved_at": "...",
      "closed_at": "..."
    }
  }
}
```

### Event-specific fields

Two events add extra fields inside `data.conversation`:

**`conversation.escalated`**

| Field               | Description                                          |
| ------------------- | ---------------------------------------------------- |
| `escalated_to_id`   | UUID of the human the conversation was escalated to. |
| `escalation_reason` | Why the conversation was escalated.                  |
| `escalated_at`      | Timestamp of the escalation.                         |

**`conversation.rated`**

| Field          | Description                             |
| -------------- | --------------------------------------- |
| `csat_score`   | The CSAT rating (integer).              |
| `csat_comment` | The optional comment the customer left. |
| `rated_at`     | Timestamp of the rating.                |

## Request headers

Keloa sends these headers on every delivery:

| Header                | Value                                                                                  |
| --------------------- | -------------------------------------------------------------------------------------- |
| `Content-Type`        | `application/json`                                                                     |
| `User-Agent`          | `Keloa-Webhooks/1`                                                                     |
| `X-Keloa-Event`       | The event type, e.g. `conversation.solved`.                                            |
| `X-Keloa-Delivery-Id` | A per-event UUID. **Use this as your idempotency key** — retries reuse the same value. |
| `X-Keloa-Webhook-Id`  | The id of the endpoint the delivery was sent to.                                       |
| `X-Keloa-Signature`   | `t=<unix>,v1=<hmac>` — see [Verifying the signature](#verifying-the-signature).        |

## Verifying the signature

The `X-Keloa-Signature` header looks like this:

```
X-Keloa-Signature: t=1747391400,v1=3f9a...c12e
```

* `t` — the Unix timestamp (seconds) when the delivery was signed.
* `v1` — an `HMAC-SHA256` hash, lowercase hex, keyed with the endpoint's signing secret.

The signed string is the timestamp, a literal dot, and the **raw request body**:

```
<t>.<raw-request-body>
```

To verify a delivery your receiver MUST:

1. Recompute the HMAC over `"<t>.<body>"` using the signing secret and compare it with `v1` using a **constant-time** comparison function.
2. Reject the request if `t` is more than \~5 minutes (300 seconds) old — this is your replay defence.

<Warning>
  Always verify against the **raw** request body, not a parsed and re-serialized one. JSON re-encoding changes whitespace and key order, which breaks the signature.
</Warning>

<CodeGroup>
  ```js Node.js theme={null}
  import crypto from 'node:crypto';

  // `rawBody` must be the exact bytes Keloa sent — not a re-serialized object.
  function verifyKeloaWebhook(rawBody, signatureHeader, secret) {
    let t;
    let v1;
    for (const part of signatureHeader.split(',')) {
      const [key, value] = part.split('=', 2);
      if (key === 't') t = value;
      if (key === 'v1') v1 = value;
    }

    // Replay defence — reject anything older than 5 minutes.
    if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) {
      throw new Error('Stale webhook');
    }

    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${t}.${rawBody}`)
      .digest('hex');

    const expectedBuf = Buffer.from(expected);
    const receivedBuf = Buffer.from(v1 ?? '');
    if (
      expectedBuf.length !== receivedBuf.length ||
      !crypto.timingSafeEqual(expectedBuf, receivedBuf)
    ) {
      throw new Error('Bad signature');
    }
  }
  ```

  ```php PHP theme={null}
  $timestamp = null;
  $v1 = null;
  foreach (explode(',', $request->header('X-Keloa-Signature')) as $part) {
      [$k, $val] = explode('=', $part, 2);
      if ($k === 't') { $timestamp = $val; }
      if ($k === 'v1') { $v1 = $val; }
  }

  // Replay defence — reject anything older than 5 minutes.
  abort_if(abs(time() - (int) $timestamp) > 300, 400, 'Stale webhook');

  $expected = hash_hmac('sha256', $timestamp.'.'.$request->getContent(), $signingSecret);
  abort_unless(hash_equals($expected, (string) $v1), 401, 'Bad signature');
  ```
</CodeGroup>

## Idempotency

Delivery is **at-least-once** — a retry can re-deliver an event you've already processed. Every delivery of the same event carries the same `X-Keloa-Delivery-Id`, so dedupe on that header: record the IDs you've handled and skip a delivery whose ID you've already seen.

## Retries & auto-disable

Delivery is queued. A non-2xx response or a timeout (10 seconds) is retried up to **5 attempts** with exponential backoff:

| Attempt | Delay after previous |
| ------- | -------------------- |
| 1       | ≈1 minute            |
| 2       | ≈5 minutes           |
| 3       | ≈30 minutes          |
| 4       | ≈2 hours             |
| 5       | ≈6 hours             |

An endpoint that fails **20 deliveries in a row** is automatically **paused**. Fix your receiver, then re-enable the endpoint from its actions menu in the Webhooks tile.

<Note>
  If an endpoint's URL becomes unsafe or unresolvable, Keloa's delivery-time security re-check fails that delivery **without retrying** it.
</Note>

## Security

* Endpoint URLs must be `https://`. Plain HTTP is rejected.
* Private, loopback, link-local, and reserved IP addresses are rejected at creation **and** re-checked at delivery time, so an endpoint can't be repointed at internal infrastructure after the fact.
* Always [verify the signature](#verifying-the-signature) on every payload — never trust an unsigned or unverifiable POST.

## Delivery log

Each endpoint keeps a **delivery log** in the UI showing the most recent deliveries — event type, status, HTTP response code, and attempt count. Use it to spot a silent or failing receiver. Records are retained for **30 days**.

## Best practices

* **Respond fast.** Acknowledge with a `2xx` immediately, then process the payload asynchronously. Don't run heavy work inside the request — you have 10 seconds before a delivery is counted as failed.
* **Verify every payload.** Reject anything that fails signature or timestamp checks.
* **Idempotent handlers.** Dedupe on `X-Keloa-Delivery-Id`.
* **One endpoint per consumer.** A separate endpoint per system is easier to monitor, pause, and rotate independently.

## Related

<CardGroup cols={2}>
  <Card title="Developers overview" icon="code" href="/developers/overview">
    The full planned API surface.
  </Card>

  <Card title="API tokens" icon="key" href="/developers/api-tokens">
    Auth for the planned API.
  </Card>
</CardGroup>
