Webhooks API reference
Lodgestory can notify your systems the moment something happens in your workspace — a message arrives, a ticket changes hands, a campaign finishes, a call ends. You register an HTTPS endpoint, pick the events you care about, and Lodgestory POSTs a signed JSON notification to it for every matching event.
This page is the developer contract: the envelope every delivery shares, the headers and signature scheme, retry behaviour, the complete event catalogue, and the exact payload of every event type. For the product walkthrough (creating endpoints in the UI, delivery log, test and replay), see the Webhooks settings guide.
At a glance
| Transport | HTTPS POST with a JSON body |
| Format | One envelope for every event; payload version 2026-05-01 |
| Authenticity | HMAC-SHA256 signature on every subscription delivery (and on automation API calls when you set a signing secret) |
| Events | 40 subscribable event types (plus a synthetic webhook.test) |
| Reliability | At-least-once; up to 6 attempts with increasing delays; 10-second timeout per attempt |
| Endpoints | Up to 30 per organisation, each with its own secret, event selection, and up to 30 custom headers. Manage them in Settings → Webhooks or with your API token |
| History | Delivery log retained 30 days, with on-demand replay |
The envelope
Every delivery — regardless of event type — is a POST with this JSON body:
{
"id": "0c6532a2-8e2a-4b6e-9d5e-1f9b1a7e4c11",
"event": "message.received",
"api_version": "2026-05-01",
"occurred_at": "2026-08-02T10:14:02.000Z",
"organisation_id": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"object": "message",
"message": {
"…": "the full object — see the per-event reference below"
}
}
}| Field | Type | Description |
|---|---|---|
id | string (UUID) | Unique delivery ID. Automatic retries of a failed delivery reuse the same id; a manual Redeliver mints a new one. Use it to deduplicate. |
event | string | The event type, e.g. ticket.assigned. Always matches the X-Webhook-Event header. |
api_version | string | Payload schema version, currently 2026-05-01. It only changes if the shape changes incompatibly. |
occurred_at | string (ISO 8601) | When the underlying event happened (not when the delivery was attempted). |
organisation_id | string (UUID) | The organisation the event belongs to. |
data.object | string | The kind of record in the payload: one of message, message_status, template, ticket, contact, chat_resolution, chat_assignment, chat_team_assignment, chat_state, chat_workflow, flow_response, survey_response, campaign, channel, group, instagram_comment, call, goal_milestone, disposition, lead, lead_attempt, member, ai_request, or test. |
data.<object> | object | The full record, under a key named after data.object (e.g. data.ticket). A point-in-time snapshot taken when the event fired. |
data.previous_attributes | object | Only on change-type events (see the catalogue below) — the value(s) before the change. |
Delivery semantics
- At-least-once. The same delivery can occasionally arrive twice (for example, if your endpoint responds slowly and a retry overlaps). Make your handler idempotent using
id/X-Webhook-Id. - No ordering guarantee. Deliveries are made in parallel and retried independently, so events can arrive out of order. Sequence with
occurred_atand the timestamps inside each object, not arrival order. - Snapshots, not live reads. The payload reflects the record at the moment the event fired. If you need the current state later, read it from the API.
- Per-endpoint filtering. An endpoint only receives the event types it subscribes to.
Delivery headers
| Header | Description |
|---|---|
Content-Type | Always application/json. |
X-Webhook-Id | The delivery ID (same as id in the body). Idempotency key. |
X-Webhook-Event | The event type (same as event in the body). Lets you route before parsing. |
X-Webhook-Timestamp | Unix time in seconds when the delivery was signed. Part of the signature input. |
X-Webhook-Signature | sha256=<hex HMAC> — see Verifying the signature. Always present on subscription deliveries; on automation API calls it is present only when the step has a signing secret. |
If you configured custom headers on the endpoint (for example, an Authorization header for your own gateway), they are sent on every delivery in addition to the above.
Verifying the signature
Every delivery is signed with the endpoint's signing secret (shown when you create the endpoint; view or rotate it any time). The signature is an HMAC-SHA256 over the timestamp header and the raw request body, joined by a dot:
signature = "sha256=" + hex( HMAC_SHA256( secret, "<X-Webhook-Timestamp>.<raw body>" ) )
To verify: recompute it from the exact bytes you received and compare against X-Webhook-Signature using a constant-time comparison. Always use the raw, unparsed body — re-serializing the JSON changes the bytes and breaks the check.
Node.js
import crypto from 'crypto';
function isValidDelivery(req, secret) {
const timestamp = req.headers['x-webhook-timestamp'];
const signature = req.headers['x-webhook-signature']; // "sha256=…"
const expected =
'sha256=' +
crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${req.rawBody}`) // the exact bytes received
.digest('hex');
return (
signature?.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
);
}Python
import hashlib, hmac
def is_valid_delivery(headers, raw_body: bytes, secret: str) -> bool:
timestamp = headers["X-Webhook-Timestamp"]
signature = headers.get("X-Webhook-Signature", "")
expected = "sha256=" + hmac.new(
secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)Replay protection. The timestamp is part of the signed input, so an attacker can't re-sign an old body with a fresh time. Reject deliveries whose X-Webhook-Timestamp is older than a few minutes (5 minutes is a sensible tolerance — remember that retries of a failed delivery are re-signed with a fresh timestamp on each attempt).
Rotation. After rotating a secret, deliveries are signed with the new secret immediately. If you need zero-gap rotation, verify against both secrets for a short window on your side.
Responding, retries, and automatic pause
- Respond with any
2xxstatus to acknowledge. Anything else — including a timeout — counts as a failure. - Each attempt waits up to 10 seconds for your response. Acknowledge fast and process asynchronously; the response body is ignored (only the first 1,000 bytes are kept for the delivery log).
- Failed deliveries are retried automatically: up to 6 attempts with roughly doubling delays, spread over about 3 minutes.
- If an endpoint keeps failing — 15 consecutive events exhausting all their retries — it is paused automatically and stops receiving traffic. Fix your endpoint, then re-enable it (re-enabling resets the failure count). Any successful delivery also resets the count.
- Every attempt is recorded in the delivery log (kept for 30 days) with status, response code, and attempt count. You can redeliver any logged delivery on demand; the replay carries the original payload with a fresh delivery ID.
- Automatic pause and redelivery apply to endpoints (subscriptions) only. Automation API calls use the same retry schedule and the same 10-second timeout, but a failing target never pauses anything and cannot be redelivered from the webhooks screen — re-run the automation instead.
Chat identifiers — read this once
Two different chat identifiers appear across payloads, and mixing them up is the most common integration bug:
chat_idonticket,chat_state,call, andgoal_milestonepayloads is the conversation ID (a UUID) — the same ID the Lodgestory API uses for chat endpoints.chat_idonmessagepayloads is the channel-level thread ID (for example[email protected]on WhatsApp) — the ID of the thread on the channel itself.goal_milestonecarries both:chat_id(conversation UUID) andchannel_chat_id(channel thread ID), so you can join goal events to either stream.instagram_commentcarries both too:chat_idis the conversation UUID of the post's comment thread (every comment on the same post shares it) andchannel_chat_idis the thread's channel-level ID,ig_media:<media id>. Comments never appear inmessagepayloads — they have their own event.
Supported events
| Event | Payload object | Fires when | previous_attributes |
|---|---|---|---|
message.sent | message | An outbound message is sent on any channel — by an agent, a bot, a campaign, or the API. | — |
message.received | message | An inbound message arrives on any channel. | — |
message.status_updated | message_status | A sent message's delivery status changes (SENT / DELIVERED / READ / FAILED), on channels that report receipts (WhatsApp Official). Push apps report through the push message status and campaign counters instead. | — |
template.created | template | A WhatsApp template is created and submitted for review. | — |
template.status_updated | template | WhatsApp's review status for a template changes (approved, rejected, paused…). | — |
template.quality_updated | template | WhatsApp's quality rating for an approved template changes (GREEN / YELLOW / RED / UNKNOWN). | qualityScore |
ticket.created | ticket | A ticket is filed — manually, by a bot journey, or via the API. | — |
ticket.status_changed | ticket | A ticket is resolved or reopened. | status |
ticket.assigned | ticket | A ticket's assignee changes (including unassignment when a member is removed). | assigned_to_user_id |
contact.created | contact | A contact is created — from a new conversation, the create-contact action, a web-chat visitor identifying themselves, a guest signing in on your own app (push notifications), or automatically when an advisor captures a disposition on a chat or call that has no linked contact (origin tells you which). Bulk imports don't fire per-record events. | — |
chat.resolved | chat_resolution | A conversation is resolved (closed) — by an agent, a bot journey's AI agent, or a workflow finishing when closing rules say so — or reopens: an admin or assignee reopens it, a new customer message auto-reopens it, or its workflow is restarted. via says which. | resolved |
chat.assigned | chat_assignment | A conversation's primary assignee is set or changed — a bot journey hands it to a human, someone assigns it manually, or an automation assigns it. Secondary-assignee additions and removals don't fire it. | assignee_user_id |
chat.team_assigned | chat_team_assignment | One or more teams are newly attached to a conversation — the routing moment, which can happen with no individual owner selected (nobody online, or everyone at their open-chat cap — the chat then waits in the team's queue). Re-transfers to already-attached teams and team removals don't fire it. | — |
chat.workflow_started | chat_workflow | A workflow (lifecycle-stage set) is attached to a conversation — the moment it enters the workflow. | — |
chat.state_changed | chat_state | A conversation moves between workflow lifecycle stages (including a workflow restart). | state_id, state_name |
campaign.created | campaign | A campaign is created. | — |
campaign.completed | campaign | A campaign finishes: COMPLETED, or FAILED when no message went out — including when its audience couldn't be prepared. Cancelled campaigns don't fire it. | status |
flow.response_received | flow_response | A customer completes a WhatsApp Flow — the submitted answers, correlated to the chat and (when applicable) the campaign or bot-journey send. | — |
push.device_registered | push_device | A new install of one of your push apps registered its device (token refreshes are silent). | — |
push.device_identified | push_device | A device was linked to a person (your backend's identify call or a contact token). | — |
push.device_invalidated | push_device | Apple or Google reported the device's token dead (uninstalled, rotated); the device was retired until the app registers again. | — |
channel.connected | channel | A channel is connected (or recovers, for WhatsApp Official health). | — |
channel.disconnected | channel | A channel is disconnected or removed. | — |
group.created | group | A WhatsApp group materialises on one of your WhatsApp Official channels. Group creation via the API is asynchronous — this event delivers the group's chat_id, correlated to your create call by request_id. | — |
instagram.comment.received | instagram_comment | Someone comments on one of your Instagram posts, reels or ads — top-level comments and replies alike — on an Instagram channel with Comments in inbox switched on. Your own account's comments never fire it. | — |
call.answered | call | A Lodgestory Voice call is picked up, mid-call — an agent answers an inbound call, or the customer answers an agent's outbound or click-to-call. For screen-pops. | — |
call.completed | call | A call on a Lodgestory Voice number ends after being answered. | — |
call.missed | call | A call on a Lodgestory Voice number ends unanswered — including a call a team member declined that nobody else answered, and a caller who only heard your closed or holiday message (business_hours.after_hours is true). | — |
call.recording_available | call | A call's recording becomes available for playback/download. | — |
call.transcript_available | call | A call's transcript becomes available. | — |
survey.response_submitted | survey_response | A survey response is submitted (or a partial response is finalised). Carries the primary score and sentiment band. | — |
goal.milestone_reached | goal_milestone | A live bot-journey session passes a Goal milestone. Builder test-runs never fire it. | — |
disposition.created | disposition | An advisor captures a disposition (outcome form) on a chat, a call, a booking or a lead — once per capture, after it is saved. Carries the template, the answers keyed by field id and the source ids. | — |
lead.created | lead | A lead is created — by hand, from an intake, a CSV import, a Meta Instant Form (Meta Lead Ads), the API or an automation. A duplicate the Track's policy reused or refused creates nothing and does not fire. | — |
lead.assigned | lead | A lead's owner actually changes — a pool claim, an assignment rule, a manager's reassignment, a return to the pool, an SLA handover or the stale rescue. Not fired for a lead created already owned (that is lead.created). | owner_user_id |
lead.stage_changed | lead | A lead moves stage. status follows the stage's kind, so "won" is lead.stage_changed with status WON. | stage_id, status |
lead.track_moved | lead | A lead moves to another Track (its stage vocabulary and fields change with it — re-read rather than patch). | track_id, stage_id |
lead.outcome_captured | lead_attempt | An outcome is recorded for one attempt on a lead: the agent's, or — for a paced call nobody answered — the dialer's own. Once per dial. A call an agent placed to the lead outside the dialer is counted when it ends, and emits this only when an agent saves its outcome. The lead rides along. | — |
lead.sla_breached | lead | A lead's first-response SLA expired with the lead still undialled. Once per lead. Says whether the breach also reassigned it. | — |
ai_request.created | ai_request | The Bot Journey AI agent raised a request for a person — a question for the team, a choice between options it proposed, or a change held until a teammate approves it (kind says which). Fires the moment it is raised, before anyone is notified. Builder test-runs never fire it. | — |
ai_request.answered | ai_request | The request left PENDING: answered, approved or rejected by a person (resolved_via says from where), or closed by Lodgestory — expired, auto-rejected because nobody answered, withdrawn by the agent, handled directly by a teammate in the conversation, or taken over from the console. status says which. | status |
ai_request.replied | ai_request | Kaylee replied to a teammate's ask-back on an open request — answered them, or asked the customer about it first. The request is still PENDING; ask_backs carries the thread, and its last entry is the one that changed. Builder test-runs never fire it. | — |
member.removed | member | A team member is removed from the organisation. | — |
webhook.test | test | Only when you press Send test (or call the test API) — never subscribable, delivered regardless of the endpoint's event selection. | — |
Call events fire for calls on Lodgestory Voice numbers. Calls carried by third-party telephony providers connected to your workspace don't produce webhooks.
Event payloads
Each section below documents one data.object type: the events that use it, every field, and a full example. All field names are snake_case; timestamps are ISO 8601 strings; any field can be null when the underlying value is absent.
message — message.sent, message.received
message — message.sent, message.receivedOne payload per message, on every channel. direction always matches the event: inbound for message.received, outbound for message.sent. Internal system entries (join notes, automated markers) never fire message events. Campaign sends fire one message.sent per recipient. Instagram comments aren't messages — they arrive as instagram.comment.received (see instagram_comment below); a private reply sent from a comment is a real DM, though, and fires message.sent on the commenter's chat.
| Field | Type | Description |
|---|---|---|
id | string | Message ID. For WhatsApp this is the provider message ID (wamid.…); other channels use their own ID format. |
chat_id | string | Channel-level thread ID (see Chat identifiers). |
channel_id | string (UUID) | The connected channel the message flowed through. |
channel_type | string | whatsapp, instagram, messenger, linkedin, email, or webwidget. |
direction | string | inbound or outbound. |
type | string | Message type — text, image, audio, video, document, location, sticker, contacts, interactive, button, and template sends. |
text | string | Text/caption content, if any. |
media_uri | string | Media location for media messages, if any. |
status | string | Latest known delivery status (SENT, DELIVERED, READ, FAILED); usually null on inbound messages. |
from_name | string | Display name of the sender (the customer on inbound). |
from_number | string | Sender's number/address on the channel. For channel_type: linkedin this is the sender's LinkedIn URN (urn:li:fsd_profile:… or urn:li:fsd_company:…), not a phone number; chat_id is the conversation id used by the messaging API. |
reply_to_message_id | string | ID of the message this one replies to, if it's a reply. |
agent_id | string (UUID) | The team member who sent it, for agent-sent outbound messages. |
via | string | null | browser_chat when the message travelled through browser chat — written by the customer on the page, or sent into it by your team or bot — and null for everything sent or received on the channel itself. A browser chat message keeps the chat's own chat_id, channel_id and channel_type, so it belongs to the same conversation; its id is Lodgestory's own (cs-… / cs-in-…), not a provider ID. |
timestamp | string | When the message was recorded. |
{
"id": "wamid.HBgMOTE5OTk5OTk5OTk5FQIAEhgg…",
"chat_id": "[email protected]",
"channel_id": "3f6f6c53-9d2a-4a0e-8f2b-6b7e5a1c2d3e",
"channel_type": "whatsapp",
"direction": "inbound",
"type": "text",
"text": "Is breakfast included with the deluxe room?",
"media_uri": null,
"status": null,
"from_name": "John Doe",
"from_number": "+919999999999",
"reply_to_message_id": null,
"agent_id": null,
"via": null,
"timestamp": "2026-08-02T10:14:02.000Z"
}message_status — message.status_updated
message_status — message.status_updatedDelivery-receipt updates for messages you sent, on channels that report them (WhatsApp Official today). Expect one event per status transition — a read message typically produces SENT → DELIVERED → READ. Failures (FAILED) include messages the provider accepted but could not deliver.
| Field | Type | Description |
|---|---|---|
message_id | string | The message whose status changed — matches message.id from the original message.sent. |
chat_id | string | May be null on status updates — correlate using message_id. |
channel_id | string (UUID) | The channel the message was sent on. |
channel_type | string | Channel type (see message). |
status | string | SENT, DELIVERED, READ, or FAILED. |
timestamp | string | When the provider reported the status. |
{
"message_id": "wamid.HBgMOTE5OTk5OTk5OTk5FQIAEhgg…",
"chat_id": null,
"channel_id": "3f6f6c53-9d2a-4a0e-8f2b-6b7e5a1c2d3e",
"channel_type": "whatsapp",
"status": "READ",
"timestamp": "2026-08-02T10:15:40.000Z"
}template — template.created, template.status_updated, template.quality_updated
template — template.created, template.status_updated, template.quality_updatedWhatsApp template lifecycle. template.created fires when the template is submitted (status is typically the initial review state); template.status_updated fires whenever WhatsApp's review status changes; template.quality_updated fires when WhatsApp's quality rating for an approved template moves (GREEN / YELLOW / RED / UNKNOWN), with the prior score in previous_attributes.qualityScore.
| Field | Type | Description |
|---|---|---|
id | string | The template's ID with WhatsApp. |
name | string | Template name. |
language | string | Language code, e.g. en, en_US. |
category | string | MARKETING, UTILITY, or AUTHENTICATION. |
status | string | Review status as reported by WhatsApp — e.g. PENDING, APPROVED, REJECTED, PAUSED. |
rejected_reason | string | Rejection reason (and recommendation, when provided) for rejected templates. |
quality_score | string | WhatsApp quality rating, when reported. |
{
"id": "1189312045678901",
"name": "booking_confirmation",
"language": "en",
"category": "UTILITY",
"status": "APPROVED",
"rejected_reason": null,
"quality_score": null
}ticket — ticket.created, ticket.status_changed, ticket.assigned
ticket — ticket.created, ticket.status_changed, ticket.assignedThe full ticket record. ticket.status_changed fires only on a genuine open ⇄ resolved transition, with previous_attributes.status. ticket.assigned fires whenever the assignee changes, with previous_attributes.assigned_to_user_id (the previous assignee, or null if it was unassigned). When a member is removed from the organisation, each of their open tickets fires ticket.assigned with assigned_to_user_id: null.
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Ticket ID. |
ticket_number | number | Human-friendly per-organisation ticket number (#42). |
chat_id | string (UUID) | The conversation the ticket belongs to, if chat-linked. |
call_log_id | string (UUID) | The call the ticket was filed from, if call-linked. |
customer_phone | string | Customer phone captured on the ticket. |
customer_email | string | Customer email captured on the ticket. |
status | string | open or resolved. |
resolved | boolean | Same fact as status, as a boolean. |
priority | string | LOW_PRIORITY, MEDIUM_PRIORITY, HIGH_PRIORITY, SOS, or NO_ACTION_REQUIRED. |
assigned_to_user_id | string (UUID) | Current assignee. |
created_by_user_id | string (UUID) | Who filed it (null for bot/API-created tickets). |
issue | object | The issue categories selected when the ticket was filed — mirrors your Ticket Workflow taxonomy for the channel. |
fields | object | null | The workflow's captured Details to capture, keyed by field id ({ "field_room_no": 204, "field_category": "Refund" }). Labels and types come from the workflow's field definitions (Settings → Ticket Workflows). null when nothing was captured. |
remarks | string | Free-text remarks trail. |
created_at | string | Creation time. |
updated_at | string | Last update time. |
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"ticket_number": 42,
"chat_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"call_log_id": null,
"customer_phone": "+919999999999",
"customer_email": null,
"status": "resolved",
"resolved": true,
"priority": "HIGH_PRIORITY",
"assigned_to_user_id": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"created_by_user_id": "77fa1329-5f0b-4c2d-9e3a-1b2c3d4e5f60",
"issue": { "Room Service": ["Late delivery"] },
"fields": { "field_room_no": 204, "field_category": "Refund" },
"remarks": "Guest called twice; comped breakfast.",
"created_at": "2026-08-01T09:00:00.000Z",
"updated_at": "2026-08-02T11:02:00.000Z"
}With, on ticket.status_changed:
"previous_attributes": { "status": "open" }contact — contact.created
contact — contact.created| Field | Type | Description |
|---|---|---|
id | string (UUID) | Contact ID. |
name | string | Full display name. |
first_name | string | First name. |
last_name | string | Last name. |
phone | string | Phone number (without country code when stored separately). |
country_code | string | Country dialling code, e.g. +91. |
email | string | Email address. |
origin | string | How the contact came to exist. MANUAL — created by a person in the CRM contact dialog. null — created by the API, a new conversation, a sync, or a web-chat visitor identifying themselves. DISPOSITION — created and linked automatically because an advisor captured a disposition on a chat or call that had no contact yet (email chats take the address, WhatsApp 1:1 chats and calls take the number; on a call the advisor types the name). PUSH — created when a guest signed in on your own app and your backend identified the device (push notifications; it does not create a lead). BOT_JOURNEY — created by a bot journey's AI agent, when it saves details the customer gave or files a lead on a conversation that has no contact yet (also mid-conversation). |
created_at | string | Creation time. |
{
"id": "b7e23ec2-9054-4c3f-a1de-2f4b8c6d0e12",
"name": "John Doe",
"first_name": "John",
"last_name": "Doe",
"phone": "9999999999",
"country_code": "+91",
"email": null,
"origin": "MANUAL",
"created_at": "2026-08-02T10:14:05.000Z"
}Treat origin: "DISPOSITION" and origin: "BOT_JOURNEY" contacts as already mid-conversation, not as new leads: a welcome sequence, a "thanks for reaching out" message, or a lead-scoring hook keyed on contact.created should skip them. The field is stamped on the event, not stored on the contact.
chat_resolution — chat.resolved
chat_resolution — chat.resolvedFires when a conversation is resolved (closed), and again when it reopens. Resolution comes from an agent's explicit Resolve & Close, a bot journey's AI agent closing it after its goodbye, or a workflow finishing when the organisation's Chat closing switch (or the workflow's own closing rule) says finishing also resolves the chat. Reopening comes from an admin or assignee flipping it back, a new customer message auto-reopening it, the AI agent undoing its own close when a customer message raced it, or the chat's workflow being restarted. resolved tells you the direction, via tells you the cause, and previous_attributes carries the prior flag.
| Field | Type | Description |
|---|---|---|
chat_id | string (UUID) | The conversation. |
channel_chat_id | string | The channel-level chat identifier (see Chat identifiers). |
channel_id | string (UUID) | The connected channel the conversation lives on. |
resolved | boolean | The new state: true = resolved, false = reopened. |
resolved_at | string | When it was resolved; null on reopen. |
resolved_by_user_id | string (UUID) | The agent who resolved it; null on reopen. |
via | string | What caused the change. AGENT — the explicit Resolve & Close / Reopen action. INBOUND_MESSAGE — a new customer message auto-reopened the chat (always resolved: false). WORKFLOW — finishing a workflow resolved the chat under the resolve-on-workflow-finish rules (resolved_by_user_id is the agent who finished it), or restarting that workflow reopened it. BOT_JOURNEY — a bot journey's AI agent resolved the chat after its goodbye (resolved_by_user_id is the channel's bot user), or reopened it at once because a customer message arrived while it was closing (resolved: false). A workflow the AI agent moves to a final stage closes the chat with WORKFLOW, as for a Workflow Stage step. |
{
"chat_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"channel_chat_id": "919812345678",
"channel_id": "3f8e2a10-9b7c-4d2e-8f01-5a6b7c8d9e0f",
"resolved": true,
"resolved_at": "2026-08-02T12:30:00.000Z",
"resolved_by_user_id": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"via": "AGENT"
}With:
"previous_attributes": { "resolved": false }A workflow-driven resolution carries the same shape with "via": "WORKFLOW"; the finishing move itself arrives separately as chat.state_changed with is_final: true. Two cases produce no chat.resolved even though a workflow finished: another workflow on the chat is still active (the chat stays open), or the agent unticked Also mark chat as resolved on that close.
chat_assignment — chat.assigned
chat_assignment — chat.assignedFires when a conversation's primary assignee is set or changed — the moment a human takes ownership: a bot journey hands the chat to an agent or team, someone assigns it manually in the CRM, or an automation's assign-chat action runs. It does not fire for secondary-assignee additions, for deassignments, or for the administrative primary re-election that runs when a team member is removed from the workspace.
| Field | Type | Description |
|---|---|---|
chat_id | string (UUID) | The conversation. |
channel_chat_id | string | The channel-level chat identifier (see Chat identifiers). |
channel_id | string (UUID) | The connected channel the conversation lives on. |
chat_name | string | The conversation's display name. |
assignee_user_id | string (UUID) | The new primary assignee. |
assignee_name | string | Their display name. |
assignee_email | string | Their email. |
previous_assignee_user_id | string (UUID) | The prior primary; null when the chat had none. |
assigned_by_user_id | string (UUID) | Who made the assignment — an agent, or the service account when a bot journey or automation did it. |
{
"chat_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"channel_chat_id": "919812345678",
"channel_id": "3f8e2a10-9b7c-4d2e-8f01-5a6b7c8d9e0f",
"chat_name": "Asha Patel",
"assignee_user_id": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"assignee_name": "Priya Sharma",
"assignee_email": "[email protected]",
"previous_assignee_user_id": null,
"assigned_by_user_id": "5c6d7e8f-9a0b-1c2d-3e4f-5a6b7c8d9e0f"
}With:
"previous_attributes": { "assignee_user_id": null }chat_team_assignment — chat.team_assigned
chat_team_assignment — chat.team_assignedFires when one or more teams are newly attached to a conversation — the routing moment. It fires even when no individual owner gets selected (an off-hours transfer where nobody on the team is available attaches the team but picks nobody — chat.assigned then never fires, and this event is the only signal). Only the newly attached teams are reported; teams already on the conversation are not repeated.
It is also the signal for capacity overflow. Organisations can cap each advisor's open chats; when every eligible member of the routed team is at their cap (or offline), the team is attached, nobody is picked, and the chat waits in the team's queue with no owner. chat.assigned follows only when someone claims it, an admin assigns it, or an automation pulls it — so a chat.team_assigned with no chat.assigned after a few minutes is the "chat is queueing" condition worth alerting on.
| Field | Type | Description |
|---|---|---|
chat_id | string (UUID) | The conversation. |
channel_chat_id | string | The channel-level chat identifier (see Chat identifiers). |
channel_id | string (UUID) | The connected channel the conversation lives on. |
chat_name | string | The conversation's display name. |
team_ids | array of string (UUID) | The newly attached teams. |
team_names | array of string | Their names, same order. |
assigned_by_user_id | string (UUID) | Who routed it — an agent, or the service account when a bot journey did it. |
{
"chat_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"channel_chat_id": "919812345678",
"channel_id": "3f8e2a10-9b7c-4d2e-8f01-5a6b7c8d9e0f",
"chat_name": "Asha Patel",
"team_ids": ["7b2e4c66-1a2b-4c3d-9e0f-a1b2c3d4e5f6"],
"team_names": ["Reservations"],
"assigned_by_user_id": "5c6d7e8f-9a0b-1c2d-3e4f-5a6b7c8d9e0f"
}chat_workflow — chat.workflow_started
chat_workflow — chat.workflow_startedFires when a workflow (lifecycle-stage set) is attached to a conversation — the moment it enters the workflow. Stage transitions and completion then flow as chat.state_changed.
| Field | Type | Description |
|---|---|---|
chat_id | string (UUID) | The conversation. |
state_machine_id | string (UUID) | The workflow attached. |
state_machine_name | string | Its name. |
state_id | string | The initial stage's ID. |
state_name | string | The initial stage's name. |
is_final | boolean | Always false — a workflow cannot start already closed. |
attached_by_user_id | string (UUID) | Who attached it — the person, or the channel's bot user for rule- and journey-attached workflows. |
trigger | string | MANUAL (a person), AUTO (a channel auto-attach rule) or BOT_JOURNEY (a bot journey — its Workflow Stage step, or its AI agent moving the stage). |
attached_at | string | When the workflow was attached. |
{
"chat_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"state_machine_id": "0d47ac10-58cc-4372-a567-0e02b2c3d479",
"state_machine_name": "Guest journey",
"state_id": "node-1",
"state_name": "New",
"is_final": false,
"attached_by_user_id": null,
"trigger": "AUTO",
"attached_at": "2026-08-02T12:30:00.000Z"
}chat_state — chat.state_changed
chat_state — chat.state_changedFires when a conversation moves between workflow lifecycle stages, including moving into a final stage and restarting a finished workflow. previous_attributes carries the stage it moved from.
| Field | Type | Description |
|---|---|---|
chat_id | string (UUID) | The conversation. |
state_machine_id | string (UUID) | The workflow (lifecycle-stage set) in play. |
state_machine_name | string | Its name. |
state_id | string | ID of the stage the conversation is now in. |
state_name | string | Name of that stage, e.g. Awaiting Guest. |
is_final | boolean | Whether the new stage is a final stage. |
responsible_user_id | string (UUID) | Team member responsible for the transition, when recorded. |
remarks | string | Transition remarks, when provided. |
changed_at | string | When the transition happened. |
{
"chat_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"state_machine_id": "0d47ac10-58cc-4372-a567-0e02b2c3d479",
"state_machine_name": "Guest journey",
"state_id": "node-3",
"state_name": "Awaiting Guest",
"is_final": false,
"responsible_user_id": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"remarks": "Waiting on guest confirmation",
"changed_at": "2026-08-02T12:30:00.000Z"
}With:
"previous_attributes": { "state_id": "node-2", "state_name": "In Progress" }campaign — campaign.created, campaign.completed
campaign — campaign.created, campaign.completedcampaign.created fires when the campaign is created — from the builder, the partner API, or an AI assistant. Its audience is usually still being prepared at that point: status is SCHEDULED and total_messages is the expected count. campaign.completed fires exactly once when the campaign ends: status is COMPLETED, or FAILED when not a single message went out — including when the audience couldn't be prepared (nobody in it could be messaged on the channel, or its segment was deleted first). A cancelled campaign never fires campaign.completed. The counters are final on the completion event.
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Campaign ID. |
name | string | Campaign name. |
status | string | SCHEDULED on campaign.created; COMPLETED or FAILED on campaign.completed. |
channel_id | string (UUID) | The channel the campaign sends on. |
total_messages | number | Total recipients (the expected count while the audience is being prepared). |
completed_messages | number | Sends that succeeded so far. |
failed_messages | number | Sends that failed so far. |
delivered_messages | number | Messages delivered (WhatsApp Official; 0 on channels without delivery receipts). |
read_messages | number | Messages read (WhatsApp Official). |
replied_messages | number | Recipients who replied. |
clicked_messages | number | Recipients who tapped a tracked link. |
message_type | string | TEMPLATE, TEXT, IMAGE, VIDEO, DOCUMENT, or INTERACTIVE. |
schedule_at | string | When the campaign sends (ISO 8601). |
audience | object | null | Where the recipients came from — see below. null for campaigns created before audiences were recorded. |
prepare_stats | object | null | How the audience became messages — see below. null until preparation records it. |
created_at | string | Creation time. |
audience
| Field | Type | Description |
|---|---|---|
kind | string | file (an uploaded CSV), list (chats and contacts picked in the builder, or recipients listed in an API request), segment (a saved segment), or retarget (a stage of a past campaign). |
segment_id | string (UUID) | null | segment only. null once the segment has been deleted. |
segment_name | string | null | segment only — kept after the segment is deleted. |
source_campaign_id | string (UUID) | null | retarget only: the past campaign. |
retarget_segment | string | null | retarget only: the stage, for example READ_NOT_REPLIED or CLICKED. |
frozen_count | number | null | segment and retarget: how many members or chats the audience had when preparation started. |
prepare_stats — counts, each present only when it is above zero: read (audience entries processed), queued (messages scheduled), and the entries skipped — duplicate (the same chat twice), invalidPhone, invalidEmail, otherChannel (a chat on another channel), notFound (a chat or contact that no longer exists), invalidRow (an unreadable entry).
{
"id": "5f7d8e9a-0b1c-2d3e-4f50-617283940a5b",
"name": "August offers",
"status": "COMPLETED",
"channel_id": "3f6f6c53-9d2a-4a0e-8f2b-6b7e5a1c2d3e",
"total_messages": 1200,
"completed_messages": 1187,
"failed_messages": 13,
"delivered_messages": 1160,
"read_messages": 902,
"replied_messages": 141,
"clicked_messages": 0,
"message_type": "TEMPLATE",
"schedule_at": "2026-08-01T09:30:00.000Z",
"audience": {
"kind": "segment",
"segment_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"segment_name": "Goa guests — summer 2026",
"source_campaign_id": null,
"retarget_segment": null,
"frozen_count": 1240
},
"prepare_stats": {
"read": 1240,
"queued": 1200,
"duplicate": 12,
"invalidPhone": 28
},
"created_at": "2026-08-01T08:00:00.000Z"
}flow_response — flow.response_received
flow_response — flow.response_receivedA customer completed a WhatsApp Flow. Every flow send carries a unique token minted by Lodgestory, so each response arrives already correlated: source says what sent the flow, and campaign_id is set for campaign sends. response is the answers object exactly as the flow's submit defined it (field names come from the flow's components); photo/document answers reference uploaded media ids.
| Field | Type | Description |
|---|---|---|
id | string (UUID) | The flow-token record's ID. |
flow_id | string | The flow's ID with WhatsApp. |
chat_id | string | The conversation (WhatsApp chat id) the response came from. |
channel_id | string (UUID) | The channel the flow was sent on. |
source | string | What sent the flow — journey, campaign, chat_template, or test_send. |
campaign_id | string (UUID) | The campaign, for campaign sends; otherwise null. |
response | object | The submitted answers as key-value pairs. |
sent_at | string | When the flow message was sent. |
responded_at | string | When the customer submitted. |
{
"id": "9a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"flow_id": "662739091060588",
"chat_id": "919888877766",
"channel_id": "3f6f6c53-9d2a-4a0e-8f2b-6b7e5a1c2d3e",
"source": "journey",
"campaign_id": null,
"response": {
"check_in": "2026-09-12",
"party_size": "4",
"room_preference": "sea_view"
},
"sent_at": "2026-08-15T09:12:44.000Z",
"responded_at": "2026-08-15T09:14:02.000Z"
}channel — channel.connected, channel.disconnected
channel — channel.connected, channel.disconnectedA connected messaging channel changed availability. The payload never includes channel credentials or tokens.
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Channel ID — matches channel_id on message/campaign payloads. |
channel_type | string | whatsapp, instagram, messenger, email, or webwidget. |
name | string | The channel's identity on its platform (e.g. the WhatsApp business number ID). |
phone_number | string | The channel's phone number, for phone-based channels. |
connected | boolean | true on channel.connected, false on channel.disconnected. |
{
"id": "3f6f6c53-9d2a-4a0e-8f2b-6b7e5a1c2d3e",
"channel_type": "whatsapp",
"name": "104857623456789",
"phone_number": "+919888877766",
"connected": true
}push_device — push.device_registered, push.device_identified, push.device_invalidated
push_device — push.device_registered, push.device_identified, push.device_invalidatedA device of one of your push apps (see Push notifications API). Never carries the provider token.
{
"id": "1d5e…",
"channel_id": "c1d2e3f4-…",
"contact_id": "33333333-…",
"external_user_id": "u_4421",
"platform": "android",
"provider": "fcm",
"app_version": "1.0.3",
"os_version": "15",
"locale": "en-IN",
"timezone": "Asia/Kolkata",
"notifications_enabled": true,
"marketing_opt_in": true,
"status": "ACTIVE",
"invalid_reason": null,
"last_seen_at": "2026-10-21T09:58:12.000Z",
"created_at": "2026-10-21T09:58:12.000Z"
}status is ACTIVE, UNSUBSCRIBED or INVALID (invalid_reason = Apple's / Google's reason, or stale after 180 days unseen). contact_id is null until identified. Push sends themselves raise no message.* events — they are not chat messages. Follow one with GET /api/wp-crm/push/{organisationId}/messages/{id} (per-device sent / delivered / opened), and a push campaign through its counters and campaign.completed.
group — group.created
group — group.createdA WhatsApp group appeared on one of your WhatsApp Official channels. Group creation via POST /api/wp-crm/groups/:orgId is asynchronous — the create call returns only a requestId; this event completes the loop by delivering the group's chat_id. Match on request_id to correlate. Groups created outside the API (e.g. from the connected phone) also fire it, with request_id: null.
The chat_id is a normal conversation ID: it works with the messaging, chats, and chat-assignment APIs, so you can immediately message the group and assign agents to it.
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Group record ID (same value as chat_id, present for consistency with other *.created payloads). |
chat_id | string (UUID) | The group's conversation ID — use it with send-message, chats, and chat-assignments. |
group_id | string | WhatsApp's opaque group ID. |
channel_id | string (UUID) | The WhatsApp Official channel the group lives on. |
subject | string | Group subject/name. |
description | string | Group description, when set. |
invite_link | string | Shareable invite link, when already known. |
join_approval_mode | boolean | true when invite-link joiners need admin approval. |
request_id | string | The requestId your create call returned; null for groups created outside the API. |
created_at | string (ISO 8601) | When the group was created. |
{
"id": "c3d4e5f6-a7b8-9012-cdef-345678901234",
"chat_id": "c3d4e5f6-a7b8-9012-cdef-345678901234",
"group_id": "120363043968812345",
"channel_id": "3f6f6c53-9d2a-4a0e-8f2b-6b7e5a1c2d3e",
"subject": "Villa Amara — Guest Group",
"description": "Concierge support for your stay, 24×7.",
"invite_link": "https://chat.whatsapp.com/JZk1aB2cD3e",
"join_approval_mode": true,
"request_id": "1234567890",
"created_at": "2026-08-15T09:12:40.000Z"
}instagram_comment — instagram.comment.received
instagram_comment — instagram.comment.receivedSomeone commented on one of your Instagram posts, reels or ads. Fires for Instagram channels that have Comments in inbox switched on (Connections), once per comment — replies to comments included (is_reply, with parent_id). Comments posted by your own account never fire it, and neither do public replies sent from Lodgestory. Comments live in the post's comment thread in the inbox: chat_id is that thread's conversation ID, shared by every comment on the same post. The media block describes the post the comment is on.
| Field | Type | Description |
|---|---|---|
id | string | Instagram's comment ID. Use it with the comments API to reply, reply privately, or hide. |
text | string | The comment text. |
username | string | The commenter's Instagram username. |
from_id | string | The commenter's Instagram-scoped ID. |
is_reply | boolean | true when the comment is a reply to another comment. |
parent_id | string | The top-level comment this one replies to; null for top-level comments. |
is_own | boolean | Whether your own account posted it — always false on this event. |
hidden | boolean | Whether the comment is hidden on Instagram. |
created_at | string | When the comment was posted. |
chat_id | string (UUID) | The post's comment-thread conversation ID (see Chat identifiers). |
channel_chat_id | string | The thread's channel-level ID — ig_media:<media id>. |
channel_id | string (UUID) | The Instagram channel (account) the post belongs to. |
channel_type | string | Always instagram. |
media | object | The post the comment is on — fields below. |
media.id | string | Instagram's media ID. |
media.product_type | string | FEED (post), REELS, or AD. |
media.media_type | string | Instagram's media type — IMAGE, VIDEO, or CAROUSEL_ALBUM — when known. |
media.caption | string | The post's caption. |
media.permalink | string | Link to the post on Instagram. |
media.thumbnail_url | string | A thumbnail of the post, when available. Instagram's media links expire — don't store them long-term. |
media.published_at | string | When the post was published. |
media.is_ad | boolean | true when the comment was left on an ad. |
media.ad_id | string | The ad's ID, for ad comments. |
media.ad_title | string | The ad's title, for ad comments. |
media.original_media_id | string | For an ad built from an organic post, that post's media ID. |
{
"id": "17895695668004550",
"text": "Is the pool villa available in June?",
"username": "jane_doe",
"from_id": "1234567890123456",
"is_reply": false,
"parent_id": null,
"is_own": false,
"hidden": false,
"created_at": "2026-08-28T10:14:02.000Z",
"chat_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"channel_chat_id": "ig_media:17900000000000000",
"channel_id": "3f6f6c53-9d2a-4a0e-8f2b-6b7e5a1c2d3e",
"channel_type": "instagram",
"media": {
"id": "17900000000000000",
"product_type": "REELS",
"media_type": "VIDEO",
"caption": "Monsoon mornings at Villa Amara 🌧️ Book your June escape.",
"permalink": "https://www.instagram.com/reel/C9xYzAbCdEf/",
"thumbnail_url": "https://scontent.cdninstagram.com/v/t51.../thumb.jpg",
"published_at": "2026-08-25T07:30:00.000Z",
"is_ad": false,
"ad_id": null,
"ad_title": null,
"original_media_id": null
}
}A private reply sent from a comment — in the inbox, by an automation, or via the API — is a real Instagram DM, so it fires message.sent on the commenter's chat, not on the thread.
call — call.answered, call.completed, call.missed, call.recording_available, call.transcript_available
call — call.answered, call.completed, call.missed, call.recording_available, call.transcript_availableAll five events share this payload — a snapshot of the call record. On call.recording_available the payload always has recording_available: true; on call.transcript_available it always has has_transcript: true. Recording/transcript events can arrive minutes after call.completed for the same call (id ties them together). Fires for Lodgestory Voice numbers only.
call.answered fires while the call is still live, the moment it is picked up: an agent answers an inbound call (browser softphone, desk phone or their own phone), or the customer answers an agent's outbound call or an API click-to-call — on a connect call, when the two people are connected. Use it to pop the customer's record on the agent's screen — look up customer_number, and route the pop by agent.email / agent.user_id. On this event status is IN_PROGRESS; the fields only known at hangup are null or 0 (hangup_cause, ended_by, ended_via, ended_at, the durations, recording and transcript flags); and chat_id is usually null, because calls are linked to a conversation at hangup. It is sent once per answering agent — after a transfer it is sent again naming the new agent (an outbound call's part after the hand-over arrives as its own record with continuation_of set). It never arrives after call.completed for the same call; if a delayed pickup is processed after the hangup, it is dropped.
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Call ID — matches call_log_id on tickets filed from calls. |
direction | string | INBOUND or OUTBOUND. |
call_type | string | connect for a connect call — two outside numbers your system joined through the API, with no agent; standard for every other call. On a connect call caller_number is customer 1 (rung first), called_number and customer_number customer 2, and agent is null. |
status | string | IN_PROGRESS on call.answered; otherwise the final call status — COMPLETED for answered calls; NO_ANSWER, CANCELLED, BUSY, or FAILED for missed ones. On a click-to-call, NO_ANSWER means nobody picked up — the customer, or the agent's own phone — and CANCELLED that the agent hung up (or it was hung up from the CRM) before the customer answered. Older click-to-calls the customer did not answer can carry CANCELLED with hangup_cause ORIGINATOR_CANCEL. |
caller_number | string | The calling party. |
called_number | string | The number dialled. |
caller_id | string | The caller ID presented. |
hangup_cause | string | Telephony hangup cause, e.g. NORMAL_CLEARING, NO_ANSWER, USER_BUSY. CALL_REJECTED on a call.missed with status NO_ANSWER means a team member declined the call and nobody else answered it (see agent_offers for who). |
ended_by | string | null | Who ended the call: CUSTOMER, AGENT (including an agent hanging up from the CRM) or SYSTEM (switch-side timeout or failure). On call.missed, CUSTOMER means the caller hung up while waiting and SYSTEM that the queue or ring timed out. null when the provider does not report it. Lodgestory Voice only. |
agent_user_id | string (UUID) | The team member who handled the call; null on missed calls. |
agent | object | null | That team member: { "user_id", "name", "email" }, for matching the agent in your own system. null when the call has no agent; name / email are null for a member whose account was deleted. |
connect | object | null | A connect call's two sides: { "status", "from", "to", "ended_by_party", "talk_seconds", "max_duration_sec", "record" }. from is customer 1 and to customer 2, each { "number", "status", "answered_at", "ring_seconds", "hangup_cause" } (to also has dialled_at, when customer 1 answered; its answered_at is when the two were connected). A side's status is answered, no_answer, busy, failed, cancelled (the call was ended while it rang) or not_dialled (customer 2, when customer 1 never answered). status is the call's outcome (COMPLETED only when the two talked); ended_by_party is from, to, system, api (a hang-up request) or max_duration (the talk time limit). The outcome fields are null until the call ends — on call.answered only the numbers are filled. null on every other call. |
customer_number | string | null | The customer's side of the call whatever the direction — caller_number on inbound, called_number on outbound. |
missed_agents | array | On missed calls: the agents that were rung, in ring order — each { "user_id", "name", "number" } (user_id/name are null when the rung extension maps to no team member). null when not applicable. |
agent_offers | array | null | What each team member the call rang did with it (Lodgestory Voice, inbound calls) — on call.completed too, so an answered call can say "Asha declined, Bala answered". One entry per member, per route and per department: { "user_id", "name", "number", "via", "department_id", "offers", "declined", "no_answer", "busy", "unreachable", "outcome", "device", "while_offline", "first_offered_at", "last_offered_at" }. user_id / name are null when the extension maps to no team member (number is the extension); via is queue (a department rang them) or direct (their own number, a repeat caller sent to them, a voice-menu option); offers is how many times the call rang them and declined / no_answer / busy / unreachable how those rings ended; outcome is the member's strongest result on the call — answered, declined, no_answer, busy, unreachable, offline (the member the call was meant for was Offline or on a break, so nothing rang and the call went to their fallback; offers is 1 and the counters stay 0), answered_elsewhere (a colleague picked up first) or cancelled (the caller hung up while it rang); device is where they answered or declined — softphone (browser, mobile app or desk phone) or phone (their follow-me number) — and null for every other outcome, where no single device decided it; while_offline is true when the member was Offline or on a break and was rung anyway because an admin allowed them direct calls while offline (their own number, a repeat caller sent to them), false otherwise. null when the call carries no such record: outbound calls, call.answered, other telephony providers, and calls from before this was recorded. missed_agents is unchanged. |
ended_via | string | null | api when the agent hung up from the CRM; null otherwise. |
transfers | array | Call transfers attempted on this call, in order (empty when none). Each: transfer_id, at, mode (blind / consult), by_user_id (who started it), by_supervisor (moved from the Team view), from_user_id (the member who had the call), to { "type": "agent" | "department" | "number", "user_id", "name", "number" }, outcome (completed, queued, recalled — target failed and the caller went back to the agent —, cancelled, no_answer / busy / declined / unreachable — target failed after the agent had left; see resolution —, customer_hangup, failed), fail_cause (no_answer, busy, declined — the target pressed Decline —, unreachable, target_hangup, cancelled or null), resolution (recalled, rerouted, released or null), queue_result (department transfers: answered, timeout, no_agent_timeout, abandoned or null), consult_seconds, completed_at. After a completed transfer agent_user_id is the receiving member (null for a number). |
continuation_of | string | null | Set on the second record an outbound call gets after a transfer: the provider call id of the first member's leg (the first record's transfers entry carries completed). The second record covers the part of the call after the hand-over, with its own duration and recording. |
department | object | null | The department queue the call was routed to by the number's routing, an IVR key or business hours (Lodgestory Voice): { "id", "name", "via", "at", "result", "wait_seconds" }. via is route (the number's own routing), failover (the previous queue failed over into it) or transfer (a member transferred the caller into it); result is how the caller left the queue — answered, timeout, no_agent_timeout (nobody was logged in), abandoned (the caller hung up while waiting), exit_key — or null while waiting; wait_seconds is the time in that queue. null when the call never reached a department queue (direct-agent numbers, outbound calls). |
departments | array | Every department queue the caller entered, in order — failovers and member transfers included (empty when none). Each: id, name, via, at, result, left_at, wait_seconds, sticky (true when the department's sticky agent answered before the queue was joined). |
channel | string | whatsapp for a WhatsApp voice call (WhatsApp calling), pstn for a phone call. |
whatsapp | object | null | WhatsApp call details, null on phone calls: { "direction", "call_id", "user_id", "business_number", "wa_id" }. direction is user_initiated (the customer called your WhatsApp number) or business_initiated (a member called the customer on WhatsApp); call_id is WhatsApp's own call id when known; user_id is the customer's business-scoped WhatsApp user id when WhatsApp sends one; business_number is your WhatsApp number in E.164; wa_id is the customer's WhatsApp number (digits). |
business_hours | object | null | Whether the call arrived in or out of hours, by the business-hours schedule that routed it (Lodgestory Voice): { "schedule_id", "schedule_name", "state", "is_open", "after_hours", "day_name", "label", "at" }. state is open, closed (outside the weekly hours), holiday (closed for a holiday), special_open / special_closed (inside / outside the hours set for a special day), forced_closed (closed with Close now) or forced_open (opened with Open now); after_hours is true for closed, holiday, special_closed and forced_closed, and is_open is its opposite; day_name is the holiday or special day that decided it ("Diwali"); label is the named period the call fell in ("Lunch cover"); at is when the schedule decided. When a call passes more than one schedule, the last decision is kept. null when the call never passed a schedule — treat it as in hours — and on call.answered, because the record is written when the call ends. A caller who only heard the closed message arrives as call.missed with after_hours: true. |
chat_id | string (UUID) | The conversation the call is linked to, when matched to one. |
duration_total_sec | number | Total seconds from initiation to hangup. |
duration_talk_sec | number | Seconds of talk time (0 for missed calls). |
duration_ring_sec | number | Seconds of ringing. |
recording_available | boolean | Whether a recording is ready. |
has_transcript | boolean | Whether a transcript is ready. |
custom_identifier | string | null | The identifier the call was placed with: lmv-… for calls started from the CRM or the API, or the customIdentifier you gave a voice broadcast. null on calls that were not placed by Lodgestory. |
reference | string | null | Your own reference on a connect call (its request's reference); null on every other call. |
initiated_at | string | When the call started. |
answered_at | string | When it was answered (null if never). |
ended_at | string | When it ended. |
{
"id": "0b1f2e3d-4c5b-6a79-8897-a6b5c4d3e2f1",
"direction": "INBOUND",
"status": "NO_ANSWER",
"caller_number": "+919999999999",
"called_number": "+918888877766",
"caller_id": "+919999999999",
"hangup_cause": "NO_ANSWER",
"agent_user_id": null,
"missed_agents": [
{ "user_id": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", "name": "Asha Rao", "number": "1001" },
{ "user_id": null, "name": null, "number": "1004" }
],
"agent_offers": [
{
"user_id": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"name": "Asha Rao",
"number": "1001",
"via": "queue",
"department_id": "0796d4bf-353d-4886-aca6-dc27969dd5ca",
"offers": 2,
"declined": 0,
"no_answer": 2,
"busy": 0,
"unreachable": 0,
"outcome": "no_answer",
"device": "softphone",
"while_offline": false,
"first_offered_at": "2026-08-02T14:05:12.000Z",
"last_offered_at": "2026-08-02T14:05:30.000Z"
},
{
"user_id": null,
"name": null,
"number": "1004",
"via": "queue",
"department_id": "0796d4bf-353d-4886-aca6-dc27969dd5ca",
"offers": 1,
"declined": 0,
"no_answer": 0,
"busy": 0,
"unreachable": 1,
"outcome": "unreachable",
"device": null,
"while_offline": false,
"first_offered_at": "2026-08-02T14:05:12.000Z",
"last_offered_at": "2026-08-02T14:05:12.000Z"
}
],
"chat_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"duration_total_sec": 32,
"duration_talk_sec": 0,
"duration_ring_sec": 28,
"recording_available": false,
"has_transcript": false,
"custom_identifier": null,
"transfers": [],
"continuation_of": null,
"department": {
"id": "0796d4bf-353d-4886-aca6-dc27969dd5ca",
"name": "Sales",
"via": "route",
"at": "2026-08-02T14:05:12.000Z",
"result": "timeout",
"wait_seconds": 30
},
"departments": [
{
"id": "0796d4bf-353d-4886-aca6-dc27969dd5ca",
"name": "Sales",
"via": "route",
"at": "2026-08-02T14:05:12.000Z",
"result": "timeout",
"left_at": "2026-08-02T14:05:42.000Z",
"wait_seconds": 30,
"sticky": false
}
],
"business_hours": {
"schedule_id": "7d2c1b0a-5e4f-4a3b-9c8d-1f2e3d4c5b6a",
"schedule_name": "Front desk hours",
"state": "open",
"is_open": true,
"after_hours": false,
"day_name": null,
"label": null,
"at": "2026-08-02T14:05:10.000Z"
},
"initiated_at": "2026-08-02T14:05:10.000Z",
"answered_at": null,
"ended_at": "2026-08-02T14:05:42.000Z"
}A call.missed for a caller who rang on a holiday and heard the holiday greeting (abridged):
{
"id": "9e8d7c6b-5a49-4382-a716-05f4e3d2c1b0",
"direction": "INBOUND",
"status": "NO_ANSWER",
"caller_number": "+919999999999",
"called_number": "+918888877766",
"customer_number": "+919999999999",
"agent_user_id": null,
"missed_agents": null,
"department": null,
"departments": [],
"business_hours": {
"schedule_id": "7d2c1b0a-5e4f-4a3b-9c8d-1f2e3d4c5b6a",
"schedule_name": "Front desk hours",
"state": "holiday",
"is_open": false,
"after_hours": true,
"day_name": "Diwali",
"label": null,
"at": "2026-11-08T06:30:02.000Z"
},
"channel": "pstn",
"duration_talk_sec": 0,
"initiated_at": "2026-11-08T06:30:01.000Z",
"answered_at": null,
"ended_at": "2026-11-08T06:30:14.000Z"
}A call.answered for an inbound call (abridged):
{
"id": "5c7d8e9f-0a1b-4c2d-9e3f-4a5b6c7d8e9f",
"direction": "INBOUND",
"status": "IN_PROGRESS",
"caller_number": "+919999999999",
"called_number": "+918888877766",
"customer_number": "+919999999999",
"hangup_cause": null,
"ended_by": null,
"agent_user_id": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"agent": {
"user_id": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"name": "Asha Rao",
"email": "[email protected]"
},
"department": { "id": "3f2e1d0c-9b8a-4765-8432-10fedcba9876", "name": "Sales", "via": "route", "at": "2026-08-02T14:05:02.000Z", "result": "answered", "wait_seconds": 6 },
"channel": "pstn",
"chat_id": null,
"duration_total_sec": null,
"duration_talk_sec": null,
"custom_identifier": null,
"initiated_at": "2026-08-02T14:05:00.000Z",
"answered_at": "2026-08-02T14:05:09.000Z",
"ended_at": null
}A WhatsApp call's channel fields:
"channel": "whatsapp",
"whatsapp": {
"direction": "user_initiated",
"call_id": "wacid.HBgMOTE5ODc2NTQzMjEwFQIAEhgg",
"user_id": null,
"business_number": "+918888877766",
"wa_id": "919999999999"
}A transferred call's transfers entry:
"transfers": [
{
"transfer_id": "5f0c8c1e-2a3b-4c5d-9e6f-7a8b9c0d1e2f",
"at": "2026-09-13T10:12:04.000Z",
"mode": "consult",
"by_user_id": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"by_supervisor": false,
"from_user_id": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"to": { "type": "agent", "user_id": "c4d5e6f7-8091-42a3-b4c5-d6e7f8091a2b", "name": "Meera Iyer", "number": null },
"outcome": "completed",
"fail_cause": null,
"resolution": null,
"queue_result": null,
"consult_seconds": 18,
"completed_at": "2026-09-13T10:12:41.000Z"
}
]A call.missed for a connect call where customer 2 did not answer (abridged):
{
"id": "7e8f9a0b-1c2d-4e3f-8a4b-5c6d7e8f9a0b",
"direction": "OUTBOUND",
"call_type": "connect",
"status": "NO_ANSWER",
"caller_number": "+919876543210",
"called_number": "+919812345678",
"caller_id": "+918888877766",
"customer_number": "+919812345678",
"agent_user_id": null,
"agent": null,
"connect": {
"status": "NO_ANSWER",
"from": { "number": "+919876543210", "status": "answered", "answered_at": "2026-10-24T09:30:06.000Z", "ring_seconds": 6, "hangup_cause": null },
"to": { "number": "+919812345678", "status": "no_answer", "dialled_at": "2026-10-24T09:30:06.000Z", "answered_at": null, "ring_seconds": 30, "hangup_cause": "NO_ANSWER" },
"ended_by_party": "system",
"talk_seconds": 0,
"max_duration_sec": 3600,
"record": true
},
"duration_talk_sec": 0,
"custom_identifier": "lmv-7e8f9a0b-1c2d-4e3f-8a4b-5c6d7e8f9a0b",
"reference": "booking-4821",
"initiated_at": "2026-10-24T09:30:00.000Z",
"answered_at": "2026-10-24T09:30:06.000Z",
"ended_at": "2026-10-24T09:30:37.000Z"
}survey_response — survey.response_submitted
survey_response — survey.response_submittedFires when a survey response is submitted (or a partial response is finalised). Sensitive request metadata (IPs, tokens, user agents) is never included.
| Field | Type | Description |
|---|---|---|
id | string (UUID) | The response. |
form_id / form_name / form_slug | string | The survey form it answers. |
version_number | number | The published form version answered. |
status | string | Response status (e.g. COMPLETED, PARTIAL). |
source | string | How it arrived (invite link, public link, in-chat…). |
primary_score | number | The form's designated score question — what score-based automations key on. |
primary_score_type | string | The score question's type (stars, NPS, thumbs…). |
score_normalised | number | The score mapped onto a 0–1 scale. |
sentiment_band | string | DETRACTOR, PASSIVE, or PROMOTER. |
invite_id / contact_id | string (UUID) | The invite and contact, when known. |
external_customer_ref | string | Your reference from a partner-API invite, when provided. |
interaction_id / interaction_type / interaction_at | string | The call/chat/ticket the survey followed, when linked. |
channel / agent_user_id / team_id / queue_id | string | Attribution of the interaction, when known. |
trigger_event | string | What sent the survey (e.g. an automation's trigger). |
metadata | object | Custom metadata from a partner-API invite. |
started_at / responded_at | string | Timing of the response. |
time_to_complete_seconds | number | How long it took. |
contact_consent | boolean | Whether the respondent consented to be contacted. |
consent_name / consent_email / consent_phone | string | Contact details they left, when consent was given. |
answers | array | Every answer. Each entry carries node_id, question_type, question_text, scale_min, scale_max, numeric_value, text_value, comment_text, selected_option_ids, selected_option_labels — the value fields not applicable to the question type are null. |
{
"id": "a1b2c3d4-e5f6-4071-8293-a4b5c6d7e8f9",
"form_id": "0d47ac10-58cc-4372-a567-0e02b2c3d479",
"form_name": "Post-stay CSAT",
"form_slug": "post-stay-csat",
"version_number": 3,
"status": "COMPLETED",
"source": "INVITE",
"primary_score": 5,
"primary_score_type": "STARS",
"score_normalised": 1,
"sentiment_band": "PROMOTER",
"invite_id": "7b2e4c66-1a2b-4c3d-9e0f-a1b2c3d4e5f6",
"contact_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"external_customer_ref": null,
"interaction_id": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"interaction_type": "CALL",
"interaction_at": "2026-08-02T12:00:00.000Z",
"channel": "WHATSAPP_OFFICIAL",
"agent_user_id": "5c6d7e8f-9a0b-1c2d-3e4f-5a6b7c8d9e0f",
"team_id": null,
"queue_id": null,
"trigger_event": "call.completed",
"metadata": null,
"started_at": "2026-08-02T12:31:00.000Z",
"responded_at": "2026-08-02T12:33:10.000Z",
"time_to_complete_seconds": 130,
"contact_consent": true,
"consent_name": "Asha Patel",
"consent_email": null,
"consent_phone": "+919812345678",
"answers": [
{
"node_id": "q-rating",
"question_type": "STARS",
"question_text": "How was your stay?",
"scale_min": 1,
"scale_max": 5,
"numeric_value": 5,
"text_value": null,
"comment_text": "Lovely villa!",
"selected_option_ids": null,
"selected_option_labels": null
}
]
}goal_milestone — goal.milestone_reached
goal_milestone — goal.milestone_reachedA live bot-journey conversation passed a Goal milestone. Carries the tracker values captured up to that point — ready to pipe conversions into your analytics or CRM. Fires for live conversations only; builder test-runs are excluded.
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Milestone event ID (each occurrence is its own event record). |
goal_id | string (UUID) | The goal. |
goal_name | string | Its name, e.g. Bookings. |
milestone_id | string (UUID) | The milestone reached. |
milestone_name | string | Its name, e.g. Booking confirmed. |
journey_id | string | The bot journey the session was running. |
chat_id | string (UUID) | Conversation ID (see Chat identifiers). |
channel_chat_id | string | Channel-level thread ID — joins to message.chat_id. |
chat_name | string | Conversation display name. |
channel_id | string (UUID) | The channel the session ran on. |
tracker_values | object | Tracker name → captured value, as of this milestone. {} when none. |
recorded_at | string | When the milestone was recorded. |
{
"id": "9e8d7c6b-5a49-3827-1605-f4e3d2c1b0a9",
"goal_id": "1a2b3c4d-5e6f-7081-92a3-b4c5d6e7f809",
"goal_name": "Bookings",
"milestone_id": "aa11bb22-cc33-dd44-ee55-ff6677889900",
"milestone_name": "Booking confirmed",
"journey_id": "journey-42",
"chat_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"channel_chat_id": "[email protected]",
"chat_name": "John Doe",
"channel_id": "3f6f6c53-9d2a-4a0e-8f2b-6b7e5a1c2d3e",
"tracker_values": { "Order_ID": "ORD-1042", "Room_Type": "Deluxe" },
"recorded_at": "2026-08-02T09:15:00.000Z"
}disposition — disposition.created
disposition — disposition.createdAn advisor captured a disposition — the outcome form on a chat, a call, a booking, or a lead in the Agent Dialer. Fires once per capture. The id matching source_type is set: chat_id, call_id or booking_id — or lead_id for a LEAD outcome, which also carries call_id when the outcome was for a call. values is keyed by the template's field ids (the template's fields array, available from the dispositions API, maps ids to labels). Lodgestory's own Meta Ads Lead Signals consume the same event in-process.
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Disposition ID. |
source_type | string | CHAT, CALL, CALENDAR_BOOKING or LEAD. |
chat_id | string (UUID) | null | Conversation ID when source_type is CHAT (see Chat identifiers). |
call_id | string (UUID) | null | Call ID when source_type is CALL. |
booking_id | string (UUID) | null | Booking ID when source_type is CALENDAR_BOOKING. |
lead_id | string (UUID) | null | Lead ID when source_type is LEAD — every outcome an agent saves in the Agent Dialer is recorded as the lead's disposition, on the Track's outcome form when it has one. |
contact_id | string (UUID) | The contact the outcome is anchored to (auto-created from the chat when it had none). |
template_id | string (UUID) | null | The disposition template used; null for a free-form capture. |
template_name | string | null | Its name, e.g. Chat outcome. |
values | object | Field id → answer (string, number, or array for multi-selects). {} when none. |
outcome_label | string | null | Optional one-line outcome. |
notes | string | null | Free-text notes. |
created_by_user_id | string (UUID) | null | The advisor who captured it. |
created_at | string | When it was captured. |
{
"id": "6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
"source_type": "CHAT",
"chat_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"call_id": null,
"booking_id": null,
"lead_id": null,
"contact_id": "0b4d5c6e-7f80-4a91-b2c3-d4e5f6a7b8c9",
"template_id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
"template_name": "Chat outcome",
"values": { "outcome": "Booked", "booking_value": "18500" },
"outcome_label": "Booked — 2 nights",
"notes": null,
"created_by_user_id": "5c9a1f2e-3b4d-4e6f-8a9b-0c1d2e3f4a5b",
"created_at": "2026-09-04T09:15:00.000Z"
}lead — lead.created, lead.assigned, lead.stage_changed, lead.track_moved, lead.sla_breached
lead — lead.created, lead.assigned, lead.stage_changed, lead.track_moved, lead.sla_breachedA lead — one enquiry in a Track, pointing at a contact. Every lead event carries the same snapshot of the lead as it stood after the change; previous_attributes (see the catalogue) holds what changed, under the same field names. Captured details are under fields, keyed by the lead type's field ids. The dedupe fingerprint is never sent.
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Lead ID. |
track_id | string (UUID) | The Track the lead is in. |
track_name | string | null | Its name, for labelling without a second call. |
lead_type_id | string (UUID) | null | The lead type, when typed. |
contact_id | string (UUID) | The contact the lead points at. |
stage_id | string | Current stage id (a Track stage, or a workflow node id on a Workflow Track). |
status | string | OPEN, WON, LOST, JUNK or MERGED — follows the kind of stage the lead is in. |
owner_user_id | string (UUID) | null | The owner; null = in the pool. |
claimed_at | string | null | When the current owner took it. |
source | string | Where it came from: MANUAL, CSV_IMPORT, WEB_FORM, PUBLIC_API, WEBHOOK, CHAT, CALL_INBOUND, CALL_MISSED, CALL_OUTBOUND, GOAL, DISPOSITION, TICKET, AUTOMATION, BOOKING or META_LEAD_ADS (a Facebook or Instagram Instant Form synced by Meta Lead Ads; source_detail is the form's name). Never changes after creation. |
source_detail | string | null | Free-text provenance (a portal name, an import id). |
utm | object | null | UTM parameters captured at intake. A Meta Instant Form lead carries source (facebook or instagram), medium lead_ads, campaign, term (ad set), content (ad) and meta_* ids (form, ad, ad set, campaign, page, platform, whether organic); a lead from a click-to-chat ad carries medium click_to_chat and meta_ad_id. |
intake_id | string (UUID) | null | The intake that created it, if any. |
fields | object | null | Captured details, field id → value. |
score | number | null | 0–10 from the Track's scoring table; null when unscored. |
priority | number | null | 0–100. |
next_action_at | string | null | The callback time. |
next_action_note | string | null | The callback note. |
first_touched_at | string | null | The first dial. |
time_to_first_dial_seconds | number | null | Creation to first dial. |
stage_entered_at | string | null | When it entered its current stage. |
last_contacted_at | string | null | The latest dial. |
attempt_count | number | Dials so far. |
connected_count | number | Dials that connected. |
last_outcome | string | null | The latest outcome code. |
sla_due_at | string | null | When the first-response SLA falls due. |
sla_breached_at | string | null | When it was breached. |
sla_action | string | null | lead.sla_breached only: what the breach did (NOTIFY, REASSIGN or BOTH). |
reassigned | boolean | null | lead.sla_breached only: whether the breach moved the lead to someone else. |
reassigned_to_user_id | string (UUID) | null | lead.sla_breached only: who it went to. |
previous_owner_user_id | string (UUID) | null | lead.sla_breached only: who had it before. |
dnd_status | string | null | The do-not-call verdict last recorded for the number. |
consent_id | string (UUID) | null | Reserved; consent capture is not available yet. |
created_by_user_id | string (UUID) | null | Who created it (null for an intake or import). |
created_at | string | Created. |
updated_at | string | Last changed. |
lead_attempt — lead.outcome_captured
lead_attempt — lead.outcome_capturedOne attempt on a lead and its outcome, with the lead embedded under lead (the same shape as above). Fires once per dial: an agent's saved outcome — or, for a paced call that nobody answered, the dialer's own report. lead.stage_changed covers where the lead ended up.
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Attempt ID. |
lead_id | string (UUID) | The lead. |
attempt_no | number | 1-based and gap-free per lead — the "3rd call" in a retry cadence is attempt_no 3. |
channel | string | CALL, or the channel of a logged touch. |
agent_user_id | string (UUID) | null | Who made it. |
call_log_id | string (UUID) | null | The call, when the dial went through Lodgestory Voice. |
disposition_id | string (UUID) | null | The disposition the outcome was recorded as (see disposition.created). |
outcome | string | null | The outcome code (INTERESTED, DID_NOT_PICK, BUSY, …). |
connected | boolean | Whether the call connected. |
script_id | string (UUID) | null | The call script followed, if any. |
script_answers | object | null | What the agent answered in the script. |
script_path | array | null | The script blocks actually walked. |
started_at | string | Started. |
ended_at | string | null | Ended. |
talk_seconds | number | null | Talk time. |
wrap_seconds | number | null | After-call work. |
retry_reason | string | null | Why the retry cadence did what it did: SCHEDULED, MAX_PER_DAY (deferred to tomorrow), MAX_ATTEMPTS or MAX_DAYS (gave up), RULE_DISABLED, NO_RULE, or TERMINAL_STATUS (the lead is closed). |
lead | object | The lead after the outcome (see lead). |
ai_request — ai_request.created, ai_request.answered, ai_request.replied
ai_request — ai_request.created, ai_request.answered, ai_request.repliedThe Bot Journey AI agent raised a request for a person — a question for the team (kind QUESTION), a choice between options it proposed (CHOICE), or a change it may only make with a teammate's OK (APPROVAL) — and, later, the request was settled; in between, Kaylee may have replied to a teammate's ask-back about it. See the Kaylee console for how requests reach your team.
ai_request.created fires the moment the request is raised, before anyone is notified — turn_user_id is still null. ai_request.answered fires once it leaves PENDING for any reason, with previous_attributes.status PENDING: a person answered, approved or rejected it (resolved_via says from where), or Lodgestory closed it (resolved_via SYSTEM) — it expired, was auto-rejected because nobody answered, was withdrawn by the agent, was handled directly by a teammate who replied in the conversation, or was taken over from the console. Requests raised in builder test-runs fire none of these events.
ai_request.replied fires while the request is still PENDING, each time Kaylee replies to a teammate's ask-back — a question a teammate put to her about the request before deciding — or asks the customer about it first. The thread is in ask_backs, and the last entry is the one that changed: status ANSWERED carries her reply to the teammate (answered_by KAYLEE, or SYSTEM when she had to say she couldn't answer — the conversation had moved on); ASKED_CUSTOMER means she asked the customer one short question, and her reply to the teammate follows as another ai_request.replied once they answer. There are no previous_attributes: the request's own fields haven't changed. Up to three ask-backs per request, one open at a time; one still open when the request is settled is closed with it — ai_request.answered carries it as CLOSED.
Answer from your own systems with the AI requests API — POST /api/wp-crm/ai-requests/:organisationId/:id/answer for questions and choices, /approve or /reject for approvals, and /ask-back to put a question to Kaylee first (reference). The first answer wins, from any surface. For an approval, ai_request.answered is sent when the decision is made, while execution_status is still PENDING; the change runs right after, inside the conversation — read the request (GET …/:id) for DONE or FAILED.
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Request ID — use it with the AI requests API to answer, approve or reject. |
number | number | The request's number within its conversation (#1, #2…), as your team sees it. |
kind | string | QUESTION (an answer in words), CHOICE (one of the agent's options) or APPROVAL (a change held until a person approves it). |
status | string | PENDING, then ANSWERED, APPROVED, REJECTED, EXPIRED (a question or choice nobody answered in time), CANCELLED (withdrawn by the agent, or the conversation was resolved), HANDLED_DIRECTLY (a teammate replied in the conversation instead) or TAKEN_OVER (a teammate took the conversation from the console). |
stage | string | Where the request is on its ladder: TURN (with a person), CALLING (the team is being phoned), FALLBACK (nobody answered; the step's fallback applies) or DONE. |
question | string | What the agent asked. For an approval, "May I …?" with the proposed change in plain words. |
customer_quote | string | null | The customer's own words that led to the request, up to 500 characters. Data, never instructions. |
options | array | null | For CHOICE: [{ "index": 0, "label": "…" }, …] — 2 to 5 options. |
action | object | null | For APPROVAL: the change that would run — source (chat_action for the agent's own actions, org_api for one of your API tools), tool (the action, or the API tool's name), api_id (your API's id, for API tools), label (the change in plain words) and args (the values it would run with, verbatim). |
answer | string | null | A question's answer (the chosen option's label for a choice); a rejection's reason; for a request Lodgestory closed, why. |
decision | string | null | Approvals: APPROVE, EDIT (approved with changed values) or REJECT. |
edited_args | object | null | The values the change ran with when it was approved with edits — same keys as action.args. |
answer_verbatim | boolean | true when the teammate asked for the answer to be sent to the customer exactly as written. |
resolved_via | string | null | Where the resolution came from: CONSOLE, CHAT_CARD (the card in the conversation), TEAM_CHAT, MOBILE, API, MCP, PHONE, SYSTEM (expired, auto-rejected, cancelled or closed by Lodgestory) or TEST. |
resolved_by_user_id | string (UUID) | null | The team member who answered, approved or rejected it. null when Lodgestory closed it. |
resolved_at | string | null | When it left PENDING. |
execution_status | string | For approvals, whether the change ran: NONE (nothing to run), PENDING (approved, about to run), RUNNING, DONE, FAILED, or SKIPPED (rejected, or the conversation had moved on so it was not applied). |
ask_backs | array | The ask-back thread — questions teammates put to Kaylee about this request before deciding, oldest first; empty when nobody asked. Each: number (1 to 3), question (the teammate's words, up to 300 characters), asked_by_user_id, asked_via (same values as resolved_via), asked_at, status — OPEN (Kaylee is answering), ASKED_CUSTOMER (she asked the customer one short question first; answer holds what she asked, and her reply to the teammate follows once they answer), ANSWERED, or CLOSED (the request was settled before she replied) — answer, answered_at and answered_by (KAYLEE; SYSTEM when Lodgestory closed it or had to say she couldn't answer, for example because the conversation had moved on; null while open). |
chat_id | string (UUID) | null | The conversation ID (see Chat identifiers). |
channel_name | string | null | The conversation's channel as Lodgestory names it, e.g. WHATSAPP, INSTAGRAM, EMAIL. |
customer_label | string | null | A masked label for the customer — a first name and initial, or a partly hidden number — never the full name or number. |
journey_id | string (UUID) | null | The bot journey the agent was running. |
agent_label | string | null | The AI Agent step's name in the builder. |
target_team_id | string (UUID) | null | The team the request is for. null when it went to named people only. |
target_team_name | string | null | That team's name. |
turn_user_id | string (UUID) | null | The team member whose turn it is (or was last). null on ai_request.created, and while nobody has had the turn — for example when the team is being phoned straight away. |
expires_at | string | When the request expires if nobody answers. |
created_at | string | When the agent raised it. |
updated_at | string | Last change. |
ai_request.created — a change held for approval:
{
"id": "6f1d2c3b-4a5e-4f60-9b7c-8d9e0f1a2b3c",
"number": 1,
"kind": "APPROVAL",
"status": "PENDING",
"stage": "TURN",
"question": "May I call \"Issue refund\" with amount: 8400, booking_ref: LS-20931?",
"customer_quote": "Please refund the 8,400 I paid for the extra night, we never used it.",
"options": null,
"action": {
"source": "org_api",
"tool": "Issue refund",
"api_id": "2b7c1d9e-3f40-4a51-b62c-7d8e9f0a1b2c",
"label": "call \"Issue refund\" with amount: 8400, booking_ref: LS-20931",
"args": { "amount": 8400, "booking_ref": "LS-20931" }
},
"answer": null,
"decision": null,
"edited_args": null,
"answer_verbatim": false,
"resolved_via": null,
"resolved_by_user_id": null,
"resolved_at": null,
"execution_status": "NONE",
"ask_backs": [],
"chat_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"channel_name": "WHATSAPP",
"customer_label": "Sofia L.",
"journey_id": "9c1e4b2a-7d3f-4e85-a6b0-1f2e3d4c5b6a",
"agent_label": "Concierge AI",
"target_team_id": "4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8",
"target_team_name": "Finance",
"turn_user_id": null,
"expires_at": "2026-10-10T14:05:00.000Z",
"created_at": "2026-10-10T10:05:00.000Z",
"updated_at": "2026-10-10T10:05:00.000Z"
}ai_request.replied — Riya, whose turn it is, asked Kaylee back from the console before deciding, and Kaylee answered her (the request is unchanged; no previous_attributes):
{
"id": "6f1d2c3b-4a5e-4f60-9b7c-8d9e0f1a2b3c",
"number": 1,
"kind": "APPROVAL",
"status": "PENDING",
"stage": "TURN",
"question": "May I call \"Issue refund\" with amount: 8400, booking_ref: LS-20931?",
"customer_quote": "Please refund the 8,400 I paid for the extra night, we never used it.",
"options": null,
"action": {
"source": "org_api",
"tool": "Issue refund",
"api_id": "2b7c1d9e-3f40-4a51-b62c-7d8e9f0a1b2c",
"label": "call \"Issue refund\" with amount: 8400, booking_ref: LS-20931",
"args": { "amount": 8400, "booking_ref": "LS-20931" }
},
"answer": null,
"decision": null,
"edited_args": null,
"answer_verbatim": false,
"resolved_via": null,
"resolved_by_user_id": null,
"resolved_at": null,
"execution_status": "NONE",
"ask_backs": [
{
"number": 1,
"question": "Did they check out a night early, or book an extra night they then didn't use?",
"asked_by_user_id": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"asked_via": "CONSOLE",
"asked_at": "2026-10-10T10:06:10.000Z",
"status": "ANSWERED",
"answer": "They checked out a night early: Sofia wrote that they left on the 8th and the room stayed empty on the 9th. Whether the booking fee is refundable isn't in the conversation or my instructions.",
"answered_at": "2026-10-10T10:06:25.000Z",
"answered_by": "KAYLEE"
}
],
"chat_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"channel_name": "WHATSAPP",
"customer_label": "Sofia L.",
"journey_id": "9c1e4b2a-7d3f-4e85-a6b0-1f2e3d4c5b6a",
"agent_label": "Concierge AI",
"target_team_id": "4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8",
"target_team_name": "Finance",
"turn_user_id": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"expires_at": "2026-10-10T14:05:00.000Z",
"created_at": "2026-10-10T10:05:00.000Z",
"updated_at": "2026-10-10T10:06:25.000Z"
}Had Kaylee needed to check with the customer first, the entry would have arrived as ASKED_CUSTOMER with her question to the customer in answer, followed by a second ai_request.replied with the entry ANSWERED once the customer replied.
ai_request.answered — the same request, approved with edits from the console (the change then runs with edited_args); the ask-back thread rides along:
{
"id": "6f1d2c3b-4a5e-4f60-9b7c-8d9e0f1a2b3c",
"number": 1,
"kind": "APPROVAL",
"status": "APPROVED",
"stage": "DONE",
"question": "May I call \"Issue refund\" with amount: 8400, booking_ref: LS-20931?",
"customer_quote": "Please refund the 8,400 I paid for the extra night, we never used it.",
"options": null,
"action": {
"source": "org_api",
"tool": "Issue refund",
"api_id": "2b7c1d9e-3f40-4a51-b62c-7d8e9f0a1b2c",
"label": "call \"Issue refund\" with amount: 8400, booking_ref: LS-20931",
"args": { "amount": 8400, "booking_ref": "LS-20931" }
},
"answer": null,
"decision": "EDIT",
"edited_args": { "amount": 8000, "booking_ref": "LS-20931" },
"answer_verbatim": false,
"resolved_via": "CONSOLE",
"resolved_by_user_id": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"resolved_at": "2026-10-10T10:08:40.000Z",
"execution_status": "PENDING",
"ask_backs": [
{
"number": 1,
"question": "Did they check out a night early, or book an extra night they then didn't use?",
"asked_by_user_id": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"asked_via": "CONSOLE",
"asked_at": "2026-10-10T10:06:10.000Z",
"status": "ANSWERED",
"answer": "They checked out a night early: Sofia wrote that they left on the 8th and the room stayed empty on the 9th. Whether the booking fee is refundable isn't in the conversation or my instructions.",
"answered_at": "2026-10-10T10:06:25.000Z",
"answered_by": "KAYLEE"
}
],
"chat_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"channel_name": "WHATSAPP",
"customer_label": "Sofia L.",
"journey_id": "9c1e4b2a-7d3f-4e85-a6b0-1f2e3d4c5b6a",
"agent_label": "Concierge AI",
"target_team_id": "4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8",
"target_team_name": "Finance",
"turn_user_id": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"expires_at": "2026-10-10T14:05:00.000Z",
"created_at": "2026-10-10T10:05:00.000Z",
"updated_at": "2026-10-10T10:08:40.000Z"
}With:
"previous_attributes": { "status": "PENDING" }A question answered by phone looks the same with kind QUESTION, status ANSWERED, the spoken answer transcribed in answer, resolved_via PHONE and action null.
member — member.removed
member — member.removedA team member was removed from the organisation. Their open chats and tickets are unassigned as part of removal — the counts tell you how many, and each unassigned ticket also fires its own ticket.assigned event (new assignee null), so you can reconcile either way.
| Field | Type | Description |
|---|---|---|
user_id | string (UUID) | The removed member. |
email | string | Their email. |
first_name | string | First name. |
last_name | string | Last name. |
organisation_id | string (UUID) | The organisation they were removed from. |
removed_roles | array | The roles they held at removal. |
unassigned_chats | number | Open chats that lost their assignee. |
unassigned_tickets | number | Open tickets that lost their assignee. |
removed_at | string | When the removal happened. |
{
"user_id": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"email": "[email protected]",
"first_name": "Asha",
"last_name": "Rao",
"organisation_id": "550e8400-e29b-41d4-a716-446655440000",
"removed_roles": ["CRM_USER"],
"unassigned_chats": 4,
"unassigned_tickets": 2,
"removed_at": "2026-08-02T16:20:00.000Z"
}test — webhook.test
test — webhook.testSent only when you trigger Send test on an endpoint. It is delivered, signed, retried, and logged exactly like a real event — use it to verify your signature check end to end. It ignores the endpoint's event selection and cannot be subscribed to.
{
"id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
"event": "webhook.test",
"api_version": "2026-05-01",
"occurred_at": "2026-08-02T17:00:00.000Z",
"organisation_id": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"object": "test",
"test": { "message": "This is a test webhook from your CRM." }
}
}Automation API calls
An automation's Call your API action posts to an endpoint you choose, through this same delivery pipeline — signed, retried, and recorded in the delivery log. It is not a subscription: nobody subscribes to automation.api_call, and it is delivered only to the URL configured on that step. See the Automations guide for how to set one up.
The envelope carries three things a subscription delivery can't: which automation fired, and the run's live context — the chat/contact/agent/ticket/call/survey values re-read at execution time, after any waits — alongside the trigger record itself in the usual data shape.
Headers. The standard delivery headers apply (X-Webhook-Id, X-Webhook-Timestamp, and X-Webhook-Signature when a signing secret is set — verified exactly as above), plus X-Webhook-Event: automation.api_call and any custom headers configured on the step. Three extra identity headers ride along:
| Header | Meaning |
|---|---|
X-Automation-Rule-Id | The automation that fired. |
X-Automation-Run-Id | This enrolment — one run per trigger occurrence. |
X-Automation-Step-Id | Which step in that run made the call. |
Idempotency. Delivery is at-least-once (retries, and a step replay after an interrupted run). Deduplicate on run_id + step_id, which are stable across every attempt — not on id, which is per-delivery.
| Field | Type | Description |
|---|---|---|
event | string | Always automation.api_call. |
automation.rule_id / rule_name | string | The automation that fired. |
automation.run_id | string (UUID) | The enrolment. Half of the idempotency key. |
automation.step_id | string (UUID) | The step that called. The other half. |
automation.trigger_event | string | The event that started the run, e.g. chat.assigned. |
data | object | The trigger's record in the same shape a subscription delivery of trigger_event would carry (data.object names the key). For a scheduled automation — which has no trigger record — data.object is "trigger". |
context.chat / contact / agent / ticket / call / survey | object | The run's evaluated context at execution time. Groups the trigger doesn't populate are present but empty. |
{
"id": "b21c8e14-9f3a-4d77-a0c2-6e5b1f2d3c4a",
"event": "automation.api_call",
"api_version": "2026-05-01",
"occurred_at": "2026-08-14T09:15:00.000Z",
"organisation_id": "550e8400-e29b-41d4-a716-446655440000",
"automation": {
"rule_id": "9d3d5405-b33e-4cd5-9b1e-106a3cb8e116",
"rule_name": "Unanswered handover watchdog",
"run_id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
"step_id": "8cfff0de-7cc4-4395-909a-5a7a312747d5",
"trigger_event": "chat.assigned"
},
"data": {
"object": "chat_assignment",
"chat_assignment": {
"chat_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"assignee_user_id": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"previous_assignee_user_id": null
}
},
"context": {
"chat": {
"id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"name": "Asha Patel",
"primaryAssigneeUserId": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
"agentRepliedSinceTrigger": false,
"resolved": false
},
"contact": { "e164": "+919812345678", "fullName": "Asha Patel" },
"agent": { "userId": "8a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", "fullName": "Priya Sharma" },
"ticket": {}, "call": {}, "survey": {}
}
}Endpoint requirements. The URL must be http(s) and resolve to a public address — loopback, private, link-local and reserved ranges are refused at connection time, so an internal service cannot be reached even via DNS that resolves privately. Redirects are followed at most three times, each re-checked. Respond 2xx to acknowledge; anything else is retried with the standard backoff.
Managing endpoints via the API
Endpoints can be managed two ways. Both work on the same endpoints and count toward the same limit of 30.
With your API token (public API)
Authenticate with the JWT from POST /api/wp-crm/token (see Accounts & sign-in) as an Admin, Account Owner or service account (CRM_BOT). The organisationId in the path must be the organisation the token was issued for. This is the API an automation platform uses for instant triggers.
| Method & path | What it does |
|---|---|
POST /api/wp-crm/webhooks/{organisationId} | Create an endpoint. 201 with the endpoint and its signing secret. No other API response returns the secret, so store it now. |
GET /api/wp-crm/webhooks/{organisationId} | List every endpoint, newest first — including the ones created in Settings → Webhooks — as { webhooks, maxWebhooks }. Secrets and custom header values are never returned; headerNames says which headers are set. |
POST /api/wp-crm/webhooks/{organisationId}/{webhookId}/test | Queue a webhook.test delivery. 202 with { queued, deliveryId }. |
DELETE /api/wp-crm/webhooks/{organisationId}/{webhookId} | Delete an endpoint and its delivery log. 200 with { deleted, webhookId }. New events stop at once; a delivery already queued, or a retry of a failed one, can still arrive for a few minutes. |
GET /api/wp-crm/webhooks/{organisationId}/samples?event=…&limit=3 | Sample events: your organisation's most recent records, each wrapped in the envelope a delivery of that event carries, as { event, samples }. Built by the same code as a delivery, so the fields match. Nothing is sent or recorded. limit is 1–10 (default 3); an empty samples means you have no such records yet. Supported events: contact.created, message.received, lead.created, call.missed, flow.response_received. |
Each endpoint is returned as webhookId, url, events, description, isActive, headerNames, consecutiveFailures, disabledAt, createdByUserId, createdAt and updatedAt. Errors: 400 for an invalid URL, an unknown event or field, or an organisation that already has 30 endpoints (on samples: an unsupported event or a limit outside 1–10); 401 for a missing or expired token or another organisation's ID; 403 for any other role; 404 for a webhookId outside your organisation.
In a sample, occurred_at is when the record's event happened, and each envelope has its own random id. A few fields can differ from the delivery that record produced: contact.origin is always null, and a lead or message shows its current state (stage, delivery status) rather than its state when the event fired.
Zapier REST Hook triggers: register one endpoint per Zap. In the trigger's subscribe step, POST with url = bundle.targetUrl and the event you want, and keep the returned webhookId; in the unsubscribe step, DELETE …/{webhookId}; in Perform List, GET …/samples?event=… and read each sample the way Perform reads a delivery.
POST /api/wp-crm/webhooks/550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer <token>
Content-Type: application/json
{ "url": "https://hooks.zapier.com/hooks/standard/1234567/abcdef/", "events": ["lead.created"], "description": "Zapier — New lead" }With a signed-in admin session (dashboard API)
Everything the Settings → Webhooks UI does is available under /wp-crm/webhooks for an Admin or Account Owner of the organisation, signed in to the dashboard. Every call names the organisation with the organisationId query parameter.
| Method & path | What it does |
|---|---|
POST /wp-crm/webhooks | Create an endpoint. Returns the record including its generated signing secret. |
GET /wp-crm/webhooks | List the organisation's endpoints. |
PUT /wp-crm/webhooks/:id | Update URL, events, description, headers, or isActive (re-enabling resets the failure counter). |
DELETE /wp-crm/webhooks/:id | Delete an endpoint. |
POST /wp-crm/webhooks/:id/rotate-secret | Generate and return a new signing secret. |
GET /wp-crm/webhooks/:id/deliveries?limit=50 | Recent deliveries for the endpoint (default 50, max 200). |
POST /wp-crm/webhooks/:id/test | Send a webhook.test delivery. |
POST /wp-crm/webhooks/deliveries/:deliveryId/redeliver | Replay a logged delivery (fresh delivery ID, original payload). |
Create/update body fields (the public API's create takes the same fields as the dashboard's create; any other field is refused with 400)
| Field | Type | Notes |
|---|---|---|
url | string | The receiving endpoint. Must be a valid URL and must not point at the Lodgestory API. Use HTTPS in production. |
events | string[] | Any set of the subscribable events listed above. |
description | string | Optional label shown in the UI. |
headers | object | Optional custom headers sent with every delivery — up to 30; names must be valid HTTP header names (≤ 256 chars); values ≤ 2,048 chars, no line breaks. |
isActive | boolean | Update only — enable/disable the endpoint. |
Limits
- 30 endpoints per organisation, however they were created — Settings, the dashboard API or the public API. Each Zapier instant trigger uses one.
- 30 custom headers per endpoint (name ≤ 256 chars, value ≤ 2,048 chars, no line breaks).
- Endpoint URLs cannot target the Lodgestory API.
- Delivery log retention: 30 days (replay is available within that window).
- Response bodies are truncated to 1,000 bytes in the delivery log.
Related pages
- Webhooks settings guide — the product walkthrough: creating endpoints, delivery log, test and replay.
- Accounts & sign-in — authenticating against the Lodgestory public API for follow-up reads.
- AI assistants (MCP) — another way to act on Lodgestory data from your own tools.
Updated about 24 hours ago
