Skip to main content
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.
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.
Manage endpoints in the app: sidebar → Settings → Integrations → the “Webhooks” tile.

Create an endpoint

1

Open the Webhooks tile

In Keloa, sidebar → Settings → Integrations → Webhooks → New endpoint.
2

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 you want this endpoint to receive.
3

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

Events

Keloa delivers five conversation lifecycle events:
EventFires when
conversation.openedA new conversation is created.
conversation.solvedA conversation is marked solved.
conversation.closedA conversation is closed.
conversation.escalatedA conversation is escalated to a human. Carries escalation fields.
conversation.ratedA 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.
{
  "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
FieldDescription
escalated_to_idUUID of the human the conversation was escalated to.
escalation_reasonWhy the conversation was escalated.
escalated_atTimestamp of the escalation.
conversation.rated
FieldDescription
csat_scoreThe CSAT rating (integer).
csat_commentThe optional comment the customer left.
rated_atTimestamp of the rating.

Request headers

Keloa sends these headers on every delivery:
HeaderValue
Content-Typeapplication/json
User-AgentKeloa-Webhooks/1
X-Keloa-EventThe event type, e.g. conversation.solved.
X-Keloa-Delivery-IdA per-event UUID. Use this as your idempotency key — retries reuse the same value.
X-Keloa-Webhook-IdThe id of the endpoint the delivery was sent to.
X-Keloa-Signaturet=<unix>,v1=<hmac> — see 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.
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.
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');
  }
}

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:
AttemptDelay 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.
If an endpoint’s URL becomes unsafe or unresolvable, Keloa’s delivery-time security re-check fails that delivery without retrying it.

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

Developers overview

The full planned API surface.

API tokens

Auth for the planned API.