Webhooks
Most of what you want to know happens while nobody is looking at a screen: a call ends, a meeting is booked, a customer becomes callable. Kleos POSTs a signed JSON envelope to a URL of yours when it does, so your product does not have to poll for it.
Setting one up
Endpoints are created in the console, not over the API. That is deliberate: an API key that leaked should not be able to redirect your event stream to somebody else's server. Give a URL, pick test or live, and tick the types you want — or tick none, which means everything, including types added later.
You are shown a signing secret exactly once, when the endpoint is created. Kleos cannot show it again; if you lose it, roll it.
The envelope
{
"id": "evt_2f7c1a90b3e64d8a51c07e9f",
"object": "event",
"type": "call.completed",
"mode": "live",
"created_at": "2026-08-04T10:06:38.000Z",
"data": {
"object": "call",
"id": "9f31c0aa-6d1b-4f2d-8a6b-71c0e2d94f13",
"customer_id": "0f2a6c4e-96a2-4f2e-8f2b-6c1a0e73d8b1",
"status": "completed",
"finished": true,
"outcome": "appointment_booked",
"duration_seconds": 267,
"appointment_id": "b71f0c2e-2a53-4a10-9d3f-5c0b7e1a9d44"
}
}data is the same object the matching GET would return, built from the same code — so a handler you write against the API works unchanged against the webhook. mode tells you whether it came from test or live traffic; a test endpoint never receives live events, and the reverse.
What Kleos sends
| Type | When |
|---|---|
call.completed | A call reached a final state. Carries the outcome, duration and the appointment id if one was booked. |
appointment.booked | One of your customers got a meeting. Carries the appointment, including the timezone it was agreed in. |
customer.ready | A customer passed every condition for live calling. Nothing is now waiting on you or on Kleos. |
balance.low | Your spendable balance crossed 25%, 10% or zero. At zero, no new call starts. |
key.rotated | An API key was rotated. The previous secret keeps working until the grace window closes. |
number.assigned | A number or caller ID was rented for one of your customers. Carries the number itself and its monthly rent. |
number.payment_due | A rented number's rent could not be taken, or will not be affordable at the next renewal. Carries `release_due_on` once the countdown has started — the day the number is lost for good. |
number.released | A rented number was given back. This is permanent: the number is gone and cannot be recovered. |
website.ready | A website you ordered has been built and is ready for you to look at before it goes live. |
website.live | A website is live. Carries its address, and hosting starts billing from this date. |
website.failed | A website build could not be completed. The build fee has been refunded in full. |
website.hosting_due | A live site's hosting could not be taken, or will not be affordable at the next renewal. Carries `suspend_due_on` once the countdown has started — the day the site goes offline. |
website.suspended | A site is offline for unpaid hosting. It is NOT deleted — carries `purge_due_on`, the day it stops being recoverable. |
New types will be added. Treat an unrecognised type as something to ignore, not something to error on — that is the one rule that keeps your handler working through every future release.
Verifying the signature
Kleos signs with Standard Webhooks, so any off-the-shelf library verifies it. Three headers arrive with every POST:
webhook-id: evt_2f7c1a90b3e64d8a51c07e9f
webhook-timestamp: 1785835598
webhook-signature: v1,K5o2s0Zx...The signature is a base64 HMAC-SHA256 of `${id}.${timestamp}.${rawBody}`. The key is your secret with the whsec_ prefix removed and the rest base64-decoded, which is what a library does for you when you hand it the secret whole. Sign the raw body bytes — parse the JSON after you have verified it, never before, or a whitespace difference invalidates a signature that was fine.
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.KLEOS_WEBHOOK_SECRET);
const event = wh.verify(rawBody, headers); // throws if it does not matchReject anything that fails, and reject a timestamp more than five minutes old — that is what stops somebody replaying a POST they captured. Two extra headers, x-kleos-event-type and x-kleos-mode, are there for routing and logging only; never make a decision on them before the signature has passed.
Responding
Answer 2xx within ten seconds. Do the slow part afterwards — a queue, a background job, anything — because a handler that books a meeting in your own CRM before replying will eventually time out on a slow day and be retried for work it already did.
Anything else is a failure and is retried: after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours, six attempts in all, then marked dead and left in the console for you to send again. One status is different — 410 Gone stops the retries immediately. A 404 does not, because a perfectly healthy endpoint 404s for a few seconds in the middle of a deploy.
Duplicates and order
Delivery is at-least-once. A response that was lost on the way back to us looks exactly like a response that never came, so the safe assumption is that you will see the same id twice one day. Record ids you have processed and ignore repeats — the same discipline as idempotency, in the other direction.
Order is not guaranteed either. A retry can land after a newer event. Where sequence matters, use created_at rather than arrival time.
How quickly
Within about a minute of the thing happening. Events are derived from what Kleos recorded rather than fired the instant it happens, which costs a little latency and buys something worth more: an event can never be missed because a send failed, and it can never describe something that was afterwards rolled back.
If you miss some anyway
A webhook is never the only copy. GET /v1/events returns the same envelopes on the events:read permission, so an endpoint that was down — or that did not exist yet — is recoverable by reading forward from the last id you handled. See the reference. The console also shows every recent attempt with its status code, and a button to send any of them again.
Rolling a secret
Rolling gives you a new secret and keeps the old one signing for a grace window you choose, up to a week. During it every POST carries two signatures in the same header — a verifier accepts the message if either matches, so you can deploy the new secret without a gap in which legitimate events are rejected.