Event reference
Every event Kooday can send to your systems — when each one fires, what its payload contains, and the envelope and signature they all share.
Kooday can tell your other systems the moment something happens — a deal is created, an order is paid, a booking is rescheduled. Each of these facts is delivered as a signed HTTP POST (a webhook) to an endpoint you choose: your CRM, your own backend, or an automation platform like n8n, Make or Zapier.
This page is the reference for the person writing the receiving side: how deliveries behave, how to verify they really came from Kooday, the envelope every event shares, and the full catalogue of events with their payloads.
Where to set it up: open Integrations in your dashboard, create a connector for your endpoint, then route the events you want to it. Subscribing is point-and-click; this page is for the code that receives them.
How deliveries behave
| Property | What you can rely on |
|---|---|
| Durability | Deliveries are queued durably — an accepted event is never lost to a restart |
| Retries | 8 attempts over roughly 10 hours, backing off from 10 seconds to 6 hours |
| After that | The delivery is parked as failed, and you can replay it from the Integrations screen |
| Ordering | Not guaranteed. Order by occurred_at and dedupe by event_id |
| Duplicates | Possible — delivery is at-least-once. Dedupe on event_id |
| Timeout | Your endpoint has 10 seconds to respond. Respond 2xx fast and process afterwards |
| Redirects | Not followed |
Your response decides what happens next: 2xx counts as delivered, 5xx or a
timeout is retried on the backoff schedule.
Each delivery carries these headers:
| Header | Meaning |
|---|---|
X-Kooday-Event | The event name, e.g. booking.created |
X-Kooday-Idempotency-Key | Stable across retries of the same delivery — a ready-made dedup key |
X-Kooday-Timestamp | Unix seconds when the delivery was signed |
X-Kooday-Signature | sha256=<hex> — see the next section |
Content-Type | application/json |
Verifying the signature
Verify before you trust. Anyone who finds your endpoint URL can POST to it; the signature is what proves a delivery came from Kooday.
Mint a signing secret for your connector with POST /api/integrations/{id}/signing-secret
(it is returned once — store it like a password; the dashboard shows only a
last-four hint, and has no button to mint one yet). With a secret minted, the signature is
an HMAC-SHA256 over "{timestamp}.{raw_body}" — the timestamp from the
X-Kooday-Timestamp header, a dot, then the raw request body exactly as
received:
import hmac, time
from hashlib import sha256
mac = hmac.new(secret, f"{ts}.{raw}".encode(), sha256).hexdigest()
ok = (hmac.compare_digest(f"sha256={mac}", signature_header)
and abs(time.time() - int(ts)) <= 300)
Compare in constant time, over the raw, unparsed body — re-serialising the JSON first will change the bytes and fail good deliveries. Rejecting anything older than 300 seconds is what stops a captured delivery being replayed later.
⚠️ The secret belongs to the connector (your receiving endpoint), not to the individual event route — several routes pointing at the same endpoint verify with the one secret. A connector with no minted secret receives the older scheme instead: an HMAC over the body alone, with no timestamp. Mint a secret; the timestamped scheme is the one to build against.
The envelope
Every delivery is the same envelope; only data differs by event.
{
"event": "booking.created",
"event_id": "9f3c2a4e-6c1b-4f7e-9a2d-1e8b0c3d5f7a",
"version": 1,
"occurred_at": "2026-08-05T10:41:22.310Z",
"tenant": { "id": "…uuid…", "slug": "tasdetailing" },
"actor": "ai",
"correlation": {
"conversation_ref": "…",
"call_sid": null,
"person_id": "…uuid…",
"deal_id": "…uuid…",
"order_id": null,
"booking_id": "…uuid…",
"action_ref": "act_7Hq2…"
},
"person": {
"id": "…uuid…",
"phone": "+919876543210",
"name": "Anita Rao"
},
"data": { }
}
| Field | Type | Notes |
|---|---|---|
event | string | domain.verb_past, lowercase, dot-separated |
event_id | uuid | Globally unique per fact — your dedup key, stable across retries |
version | int | The event’s payload version (see below) |
occurred_at | ISO-8601 UTC | When the fact happened, not when it was delivered |
tenant | object | Your tenant id and slug |
actor | ai | human | system | external | Who caused the fact |
correlation | object | Every reference Kooday holds for the fact — conversation, call, person, deal, order, booking, action. Unknown ones are null. This is your join key |
person | object | null | A convenience snapshot of the customer. This is PII — turn it off per route with include_person=false if the receiving system must not hold customer data |
data | object | The event-specific payload, documented per event below |
A few events predate the envelope and deliver their payload bare — the catalogue marks each one, and any route can opt those into the envelope.
Versioning
- New optional fields arrive without a version bump — write your consumer to ignore fields it does not recognise.
- A rename, removal or type change bumps
version, and the old version keeps emitting for a deprecation window. - A route can pin a version. A pinned route never receives a shape it did not ask for: when the emitted version moves past the pin, that route’s deliveries pause until you re-pin, rather than silently changing shape under your parser.
Event catalogue
This catalogue is generated directly from Kooday’s code, so it always matches what actually ships.
Kooday currently emits 45 events.
| Event | v | Envelope | Fires when |
|---|---|---|---|
call.completed | 1 | bare payload | A voice call finished and its post-call record is ready |
campaign.outcome | 1 | bare payload | Per-target outcome within a running campaign |
campaign.finished | 1 | bare payload | A campaign run finished processing all of its targets |
broadcast.finished | 1 | v2 | A broadcast finished: every target has been sent, suppressed or failed |
conversation.ended | 1 | v2 | A conversation closed (idle-close or an explicit end) |
handoff.requested | 1 | v2 | The AI or the customer asked for a human to take over |
person.identified | 1 | v2 | An anonymous visitor became a known person, or a new person was linked to an existing contact |
lead.captured | 1 | v2 | Intake fields were collected from a conversation |
person.opted_out | 1 | v2 | A customer asked to stop receiving proactive messages, or an existing opt-out was escalated to cover everything |
person.resumed | 1 | v2 | A customer asked to resume messaging (start / start all) |
deal.created | 1 | v2 | A deal was created from intake |
deal.stage_changed | 1 | v2 | A deal moved between pipeline stages |
deal.won | 1 | v2 | A deal reached a terminal won stage |
deal.lost | 1 | v2 | A deal reached a terminal lost stage |
deal.chase_halted | 1 | v2 | The AI stopped following up on a deal because the customer ended it |
deal.chase_resumed | 1 | v2 | A halted deal’s customer came back, so the AI may follow up again |
deal.takeover | 1 | v2 | A human took a deal over from the agent |
quote.proposed | 1 | v2 | A quote was proposed to the customer (server-computed totals) |
quote.accepted | 1 | v2 | The customer explicitly confirmed the quote (button tap or explicit confirmation, never inferred prose) |
quote.expired | 1 | v2 | A proposed quote passed its expiry without an answer |
order.placed | 1 | v2 | An accepted quote became an order |
order.paid | 1 | v2 | Payment arrived for an order |
order.cancelled | 1 | v2 | An order was cancelled before fulfilment |
order.fulfilled | 1 | v2 | A paid order was fulfilled |
booking.created | 1 | v2 | A booking request was submitted (fields collected and confirmed) |
booking.confirmed | 1 | v2 | The booking was confirmed (human in v1; external hook when P-b lands) |
booking.cancelled | 1 | v2 | A booking was cancelled |
booking.rescheduled | 1 | v2 | A booking moved to a new slot |
cart.abandoned | 1 | v2 | A proposed quote sat idle past the tenant’s cart_abandon_hours |
integration.unhealthy | 1 | v2 | A connector crossed the consecutive-failure threshold and delivery to it is paused (delivered to the tenant’s OTHER routes) |
message.sent | 1 | v2 | An outbound message was queued for a customer (per-message volume — routes opt in knowingly) |
action.submitted | 1 | v2 | A tenant_action was submitted (fields validated + confirmed) — the generic twin every action fires alongside its configured event |
action.completed | 1 | v2 | An async action was resolved by the tenant’s system via /api/hooks (resolve_action) |
action.failed | 1 | v2 | An action could not complete — sync_call error, async timeout, or validation failure |
invoice.issued | 1 | v2 | A monthly invoice is computed and final for its period — the trigger an accounting sync, an AP workflow or a payment integration would wait for |
invoice.needs_attention | 1 | v2 | A period closed but its invoice could not be AUTO-issued (#658) because the tenant’s billing/GST profile is incomplete — close_period wrote the degraded, untaxed fallback rather than a taxed row |
plan.changed | 1 | v2 | A tenant’s plan moved, or they asked for it to |
command.write_executed | 1 | v2 | A staff member’s WhatsApp command committed a write — a dictated note (and any temperature it carried) is on the record |
tenant.signed_up | 1 | v2 | A new organisation signed itself up for a trial |
tenant.closed | 1 | v2 | A tenant closed its own account, or a platform admin deactivated one (#656) — see TenantClosedData for why this exists despite the design’s default ‘events: none’ for account lifecycle |
bot.created | 1 | v2 | A tenant created a new assistant through the self-serve archetype wizard (the tenant door of #339, distinct from the platform-admin wizard which does not emit this) |
persona.bound | 1 | v2 | A tenant customized a persona: forked a platform archetype into their own editable copy, or pointed a bot at one (or back at a platform archetype) |
bot.blocked_by_platform | 1 | v2 | An assistant became unable to serve calls or chats for a reason that is NOT the tenant’s own configuration — a platform-side shared building block is broken |
wallet.balance_low | 1 | v2 | A tenant’s prepaid wallet crossed 90% consumed since its last top-up — a heads-up before wallet.exhausted actually stops service |
wallet.exhausted | 1 | v2 | A tenant’s prepaid wallet balance reached zero or below — the moment serving_state.py starts refusing new admission for this tenant on the balance_empty reason |
call.completed (v1)
A voice call finished and its post-call record is ready.
Kept indefinitely.
Delivered bare. This event predates the envelope: the body is the payload itself, so read payload.<field> rather than payload.data.<field>. Your route can opt into the envelope on the Integrations screen.
data: the record as Kooday holds it, delivered unchanged — no fixed schema is enforced.
campaign.outcome (v1)
Per-target outcome within a running campaign.
Kept indefinitely.
Delivered bare. This event predates the envelope: the body is the payload itself, so read payload.<field> rather than payload.data.<field>. Your route can opt into the envelope on the Integrations screen.
data: the record as Kooday holds it, delivered unchanged — no fixed schema is enforced.
campaign.finished (v1)
A campaign run finished processing all of its targets.
Kept indefinitely.
Delivered bare. This event predates the envelope: the body is the payload itself, so read payload.<field> rather than payload.data.<field>. Your route can opt into the envelope on the Integrations screen.
data: the record as Kooday holds it, delivered unchanged — no fixed schema is enforced.
broadcast.finished (v1)
A broadcast finished: every target has been sent, suppressed or failed.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
name? | string | |
campaign_type? | string | |
total_targets? | int | |
sent? | int | |
suppressed? | int | |
failed? | int | |
replied? | int | |
finished_at? | string |
conversation.ended (v1)
A conversation closed (idle-close or an explicit end). The chat sibling of call.completed.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
channel? | string | |
duration_seconds? | int | |
message_count? | int | |
summary? | string | |
intake? | object |
handoff.requested (v1)
The AI or the customer asked for a human to take over.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
reason? | string | |
channel? | string | |
requested_by? | string |
person.identified (v1)
An anonymous visitor became a known person, or a new person was linked to an existing contact.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
channel? | string | |
identities? | object | |
contact_id? | string |
lead.captured (v1)
Intake fields were collected from a conversation.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
fields? | object | |
source? | string |
person.opted_out (v1)
A customer asked to stop receiving proactive messages, or an existing opt-out was escalated to cover everything. tier is the EFFECTIVE tier now in force, not the change.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
tier? | string | |
channel? | string | |
detected_by? | string | |
evidence? | string | |
scope_requested? | string |
person.resumed (v1)
A customer asked to resume messaging (start / start all). tier is the EFFECTIVE tier still in force afterwards, or null if none remains.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
tier? | string | |
channel? | string | |
detected_by? | string | |
evidence? | string | |
scope_requested? | string |
deal.created (v1)
A deal was created from intake.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
pipeline? | string | |
stage? | string | |
source? | string | |
value_estimate? | number | |
currency? | string |
deal.stage_changed (v1)
A deal moved between pipeline stages.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
from_stage? | string | |
to_stage? | string | |
reason? | string |
deal.won (v1)
A deal reached a terminal won stage.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
stage? | string | |
value? | number | |
currency? | string | |
reason? | string | |
quote_id? | string | |
broadcast_id? | string |
deal.lost (v1)
A deal reached a terminal lost stage.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
stage? | string | |
value? | number | |
currency? | string | |
reason? | string | |
quote_id? | string | |
broadcast_id? | string |
deal.chase_halted (v1)
The AI stopped following up on a deal because the customer ended it. ⚠️ The deal remains OPEN — this is not a close, and only a person may mark a deal won or lost. Per-deal, unlike person.opted_out.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
reason? | string |
deal.chase_resumed (v1)
A halted deal’s customer came back, so the AI may follow up again. The counterpart to deal.chase_halted — a subscriber that saw the halt needs this to avoid holding a stale state.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
reason? | string |
deal.takeover (v1)
A human took a deal over from the agent.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
taken_by? | string | |
from_actor? | string |
quote.proposed (v1)
A quote was proposed to the customer (server-computed totals).
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
quote_id? | string | |
items? | array | |
totals? | object | |
offer_applied? | object | |
valid_until? | string | |
accepted_via? | string |
quote.accepted (v1)
The customer explicitly confirmed the quote (button tap or explicit confirmation, never inferred prose).
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
quote_id? | string | |
items? | array | |
totals? | object | |
offer_applied? | object | |
valid_until? | string | |
accepted_via? | string |
quote.expired (v1)
A proposed quote passed its expiry without an answer.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
quote_id? | string | |
proposed_at? | string |
order.placed (v1)
An accepted quote became an order.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
order_id? | string | |
quote_id? | string | |
items? | array | |
totals? | object | |
payment_mode? | string |
order.paid (v1)
Payment arrived for an order.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
order_id? | string | |
payment_ref? | string | |
amount? | object |
order.cancelled (v1)
An order was cancelled before fulfilment.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
order_id? | string | |
by? | string | |
reason? | string |
order.fulfilled (v1)
A paid order was fulfilled.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
order_id? | string | |
by? | string | |
reason? | string |
booking.created (v1)
A booking request was submitted (fields collected and confirmed).
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
booking_id? | string | |
action_name? | string | |
fields? | object | |
slot? | object | |
offering? | object | |
sync_mode? | string |
booking.confirmed (v1)
The booking was confirmed (human in v1; external hook when P-b lands).
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
booking_id? | string | |
external_ref? | string | |
slot? | object |
booking.cancelled (v1)
A booking was cancelled.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
booking_id? | string | |
by? | string | |
old_slot? | object | |
new_slot? | object |
booking.rescheduled (v1)
A booking moved to a new slot.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
booking_id? | string | |
by? | string | |
old_slot? | object | |
new_slot? | object |
cart.abandoned (v1)
A proposed quote sat idle past the tenant’s cart_abandon_hours.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
quote_id? | string | |
idle_hours? | int | |
items? | array | |
totals? | object |
integration.unhealthy (v1)
A connector crossed the consecutive-failure threshold and delivery to it is paused (delivered to the tenant’s OTHER routes).
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
integration? | object | |
consecutive_failures? | int | |
last_error? | string | |
paused_until? | string |
message.sent (v1)
An outbound message was queued for a customer (per-message volume — routes opt in knowingly).
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
channel? | string | |
kind? | string | |
text? | string | |
intent_type? | string | |
media_ref? | string | |
correlation? | object |
action.submitted (v1)
A tenant_action was submitted (fields validated + confirmed) — the generic twin every action fires alongside its configured event.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
action_name? | string | |
fields? | object | |
sync_mode? | string | |
action_ref? | string | |
result? | object |
action.completed (v1)
An async action was resolved by the tenant’s system via /api/hooks (resolve_action).
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
action_ref? | string | |
action_name? | string | |
result? | object |
action.failed (v1)
An action could not complete — sync_call error, async timeout, or validation failure.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
action_ref? | string | |
action_name? | string | |
stage? | string | |
error? | string |
invoice.issued (v1)
A monthly invoice is computed and final for its period — the trigger an accounting sync, an AP workflow or a payment integration would wait for. status names the EVENT’s meaning, not the invoice column: number is null at fire time — it is only ever populated by the SEPARATE numbering step (invoice_issue.issue(), run either automatically right after close, by invoice_scheduler.run_once, or by a platform admin’s override — #658), so read the invoice back if you need the numbered document. invoice.generated/draft and per-line events are deliberately not emitted (design §8); invoice.paid belongs to the payment-gateway work, not here.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
invoice? | object | |
totals? | object | |
status? | string |
invoice.needs_attention (v1)
A period closed but its invoice could not be AUTO-issued (#658) because the tenant’s billing/GST profile is incomplete — close_period wrote the degraded, untaxed fallback rather than a taxed row. Genuinely dual-purpose, same as wallet.exhausted: a tenant’s own finance tooling can act on it (their invoicing is stalled on THEM), and it is also in platform_notify.ADMIN_ACTIVITY_EVENTS so platform staff get paged. Fired at most once per invoice — a probe against domain_events before emitting keeps an unresolved profile from re-alerting on every scheduler tick. Superseded (never re-fired) once the invoice is later issued, manually or automatically; there is no separate ‘resolved’ event.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
invoice? | object | |
reason? | string |
plan.changed (v1)
A tenant’s plan moved, or they asked for it to. status separates the two: ‘pending’ is the request — recorded and awaiting payment (the rail is #382) or a period boundary — and ‘applied’ is the move itself. Both are emitted because the questions differ: an accounting sync waits for ‘applied’, a CRM wants to know at ‘pending’. losing/gaining name capability KEYS rather than a price delta, because what a plan change means downstream is which capabilities the tenant now holds — the money is on the invoice.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
from_plan? | string | |
to_plan? | string | |
direction? | string | |
status? | string | |
losing? | array | |
gaining? | array | |
effective_at? | string |
command.write_executed (v1)
A staff member’s WhatsApp command committed a write — a dictated note (and any temperature it carried) is on the record. Emitted in the same transaction as the write, only on the confirmed execution: reads, refusals, pending confirmations and expiries emit nothing.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
capability? | string | |
agent_id? | string | |
note_id? | string | |
deal_id? | string | |
temperature? | string | |
cancelled_tasks? | int | |
task_id? | string |
tenant.signed_up (v1)
A new organisation signed itself up for a trial. The one event the self-service funnel emits: a signup is a commercial lead an integrator would plausibly route to a CRM. Carries which identity KIND was verified, never the address itself.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
tenant_slug? | string | |
tenant_name? | string | |
plan? | string | |
verified_via? | string | |
trial_ends_at? | string |
tenant.closed (v1)
A tenant closed its own account, or a platform admin deactivated one (#656) — see TenantClosedData for why this exists despite the design’s default ‘events: none’ for account lifecycle. Fired at most once per close; NOT re-fired by the 30d/60d sweeper transitions or by a purge.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
tenant_slug? | string | |
tenant_name? | string |
bot.created (v1)
A tenant created a new assistant through the self-serve archetype wizard (the tenant door of #339, distinct from the platform-admin wizard which does not emit this).
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
bot_id? | string | |
slug? | string | |
name? | string | |
archetype? | string |
persona.bound (v1)
A tenant customized a persona: forked a platform archetype into their own editable copy, or pointed a bot at one (or back at a platform archetype). action says which.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
action? | string | |
persona? | string | |
archetype? | string | |
bot_id? | string | |
slug? | string |
bot.blocked_by_platform (v1)
An assistant became unable to serve calls or chats for a reason that is NOT the tenant’s own configuration — a platform-side shared building block is broken. Fires once per spell of being blocked, not once per check.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
bot_slug? | string | |
issue_count? | int |
wallet.balance_low (v1)
A tenant’s prepaid wallet crossed 90% consumed since its last top-up — a heads-up before wallet.exhausted actually stops service. Fires once per crossing, not once per debit.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
balance_paise? | string | |
threshold_percent? | int |
wallet.exhausted (v1)
A tenant’s prepaid wallet balance reached zero or below — the moment serving_state.py starts refusing new admission for this tenant on the balance_empty reason.
Kept indefinitely.
data field | Type | Notes |
|---|---|---|
balance_paise? | string |
Every data field is optional on the wire: an emitter that cannot supply one omits it rather than failing the customer’s conversation. Tolerate a missing key.
Still stuck? We answer support mail the same working day.
Email support