How it worksFeaturesSolutionsGuides PricingFAQ Ask on WhatsApp
Help Developer reference

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.

Updated 19 August 2026

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

PropertyWhat you can rely on
DurabilityDeliveries are queued durably — an accepted event is never lost to a restart
Retries8 attempts over roughly 10 hours, backing off from 10 seconds to 6 hours
After thatThe delivery is parked as failed, and you can replay it from the Integrations screen
OrderingNot guaranteed. Order by occurred_at and dedupe by event_id
DuplicatesPossible — delivery is at-least-once. Dedupe on event_id
TimeoutYour endpoint has 10 seconds to respond. Respond 2xx fast and process afterwards
RedirectsNot 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:

HeaderMeaning
X-Kooday-EventThe event name, e.g. booking.created
X-Kooday-Idempotency-KeyStable across retries of the same delivery — a ready-made dedup key
X-Kooday-TimestampUnix seconds when the delivery was signed
X-Kooday-Signaturesha256=<hex> — see the next section
Content-Typeapplication/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": { }
}
FieldTypeNotes
eventstringdomain.verb_past, lowercase, dot-separated
event_iduuidGlobally unique per fact — your dedup key, stable across retries
versionintThe event’s payload version (see below)
occurred_atISO-8601 UTCWhen the fact happened, not when it was delivered
tenantobjectYour tenant id and slug
actorai | human | system | externalWho caused the fact
correlationobjectEvery reference Kooday holds for the fact — conversation, call, person, deal, order, booking, action. Unknown ones are null. This is your join key
personobject | nullA 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
dataobjectThe 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.

EventvEnvelopeFires when
call.completed1bare payloadA voice call finished and its post-call record is ready
campaign.outcome1bare payloadPer-target outcome within a running campaign
campaign.finished1bare payloadA campaign run finished processing all of its targets
broadcast.finished1v2A broadcast finished: every target has been sent, suppressed or failed
conversation.ended1v2A conversation closed (idle-close or an explicit end)
handoff.requested1v2The AI or the customer asked for a human to take over
person.identified1v2An anonymous visitor became a known person, or a new person was linked to an existing contact
lead.captured1v2Intake fields were collected from a conversation
person.opted_out1v2A customer asked to stop receiving proactive messages, or an existing opt-out was escalated to cover everything
person.resumed1v2A customer asked to resume messaging (start / start all)
deal.created1v2A deal was created from intake
deal.stage_changed1v2A deal moved between pipeline stages
deal.won1v2A deal reached a terminal won stage
deal.lost1v2A deal reached a terminal lost stage
deal.chase_halted1v2The AI stopped following up on a deal because the customer ended it
deal.chase_resumed1v2A halted deal’s customer came back, so the AI may follow up again
deal.takeover1v2A human took a deal over from the agent
quote.proposed1v2A quote was proposed to the customer (server-computed totals)
quote.accepted1v2The customer explicitly confirmed the quote (button tap or explicit confirmation, never inferred prose)
quote.expired1v2A proposed quote passed its expiry without an answer
order.placed1v2An accepted quote became an order
order.paid1v2Payment arrived for an order
order.cancelled1v2An order was cancelled before fulfilment
order.fulfilled1v2A paid order was fulfilled
booking.created1v2A booking request was submitted (fields collected and confirmed)
booking.confirmed1v2The booking was confirmed (human in v1; external hook when P-b lands)
booking.cancelled1v2A booking was cancelled
booking.rescheduled1v2A booking moved to a new slot
cart.abandoned1v2A proposed quote sat idle past the tenant’s cart_abandon_hours
integration.unhealthy1v2A connector crossed the consecutive-failure threshold and delivery to it is paused (delivered to the tenant’s OTHER routes)
message.sent1v2An outbound message was queued for a customer (per-message volume — routes opt in knowingly)
action.submitted1v2A tenant_action was submitted (fields validated + confirmed) — the generic twin every action fires alongside its configured event
action.completed1v2An async action was resolved by the tenant’s system via /api/hooks (resolve_action)
action.failed1v2An action could not complete — sync_call error, async timeout, or validation failure
invoice.issued1v2A 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_attention1v2A 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.changed1v2A tenant’s plan moved, or they asked for it to
command.write_executed1v2A staff member’s WhatsApp command committed a write — a dictated note (and any temperature it carried) is on the record
tenant.signed_up1v2A new organisation signed itself up for a trial
tenant.closed1v2A 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.created1v2A 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.bound1v2A 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_platform1v2An 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_low1v2A tenant’s prepaid wallet crossed 90% consumed since its last top-up — a heads-up before wallet.exhausted actually stops service
wallet.exhausted1v2A 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 fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
channel?string
identities?object
contact_id?string

lead.captured (v1)

Intake fields were collected from a conversation.

Kept indefinitely.

data fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
tier?string
channel?string
detected_by?string
evidence?string
scope_requested?string

deal.created (v1)

A deal was created from intake.

Kept indefinitely.

data fieldTypeNotes
pipeline?string
stage?string
source?string
value_estimate?number
currency?string

deal.stage_changed (v1)

A deal moved between pipeline stages.

Kept indefinitely.

data fieldTypeNotes
from_stage?string
to_stage?string
reason?string

deal.won (v1)

A deal reached a terminal won stage.

Kept indefinitely.

data fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
reason?string

deal.takeover (v1)

A human took a deal over from the agent.

Kept indefinitely.

data fieldTypeNotes
taken_by?string
from_actor?string

quote.proposed (v1)

A quote was proposed to the customer (server-computed totals).

Kept indefinitely.

data fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
quote_id?string
proposed_at?string

order.placed (v1)

An accepted quote became an order.

Kept indefinitely.

data fieldTypeNotes
order_id?string
quote_id?string
items?array
totals?object
payment_mode?string

order.paid (v1)

Payment arrived for an order.

Kept indefinitely.

data fieldTypeNotes
order_id?string
payment_ref?string
amount?object

order.cancelled (v1)

An order was cancelled before fulfilment.

Kept indefinitely.

data fieldTypeNotes
order_id?string
by?string
reason?string

order.fulfilled (v1)

A paid order was fulfilled.

Kept indefinitely.

data fieldTypeNotes
order_id?string
by?string
reason?string

booking.created (v1)

A booking request was submitted (fields collected and confirmed).

Kept indefinitely.

data fieldTypeNotes
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 fieldTypeNotes
booking_id?string
external_ref?string
slot?object

booking.cancelled (v1)

A booking was cancelled.

Kept indefinitely.

data fieldTypeNotes
booking_id?string
by?string
old_slot?object
new_slot?object

booking.rescheduled (v1)

A booking moved to a new slot.

Kept indefinitely.

data fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
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 fieldTypeNotes
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