LayBuild emits four webhook events: a conversation was created, a message was received, a conversation was handed off, and a conversation was closed. There is no Kafka and no event bus behind them. Each event is one HTTP POST per subscribed endpoint, with a 5-second timeout and no retry.
That is enough to drive CRM updates, alerts and a support analytics table, as long as your receiver is built for what the sender does and does not promise. This post covers the events, the delivery behaviour you need to design around, and a receiver pattern that holds up.
The four events and when they fire
| Event | Fires when | Useful fields in data |
|---|---|---|
CONVERSATION_CREATED | A conversation starts from the website widget, WhatsApp, the in-app chat or the API | id, subject, status, customerId, createdAt; widget adds source, channel, pageUrl, pageTitle; WhatsApp adds source and phone |
MESSAGE_RECEIVED | A customer, a human agent or the AI posts a message | conversationId, messageId, role (USER, AGENT or ASSISTANT), content, createdAt; WhatsApp adds channel and phone |
CONVERSATION_HANDOFF | A handoff to a person is requested, or a person claims the conversation | id, status, reason (ai_unable, ai_turn_limit_reached or agent_claim), assignedAgent, userId |
CONVERSATION_CLOSED | A customer or agent closes it, or it is closed after 24 hours of inactivity | id, status, closedBy (customer, agent or system), userId; automatic closes add reason: "inactivity_24h" |
Two things about that table are worth knowing before you write code. The data shape varies by where the event came from: widget and WhatsApp payloads carry different optional fields, and some sources include an agent object where others include agentId. Parse defensively and treat every field except the IDs as optional. And AI replies arrive as MESSAGE_RECEIVED with role ASSISTANT, so subscribing to that event means one delivery per message in both directions.
Every delivery wraps data in the same envelope:
{
"event": "CONVERSATION_HANDOFF",
"id": "a delivery UUID",
"timestamp": "an ISO 8601 time, e.g. 2026-09-25T10:15:30.123Z",
"data": { "id": "conversation UUID", "status": "HANDOFF", "reason": "ai_unable" }
}The same delivery ID and timestamp are sent as the X-Webhook-Delivery and X-Webhook-Timestamp headers, and the event name as X-Webhook-Event. If you set a secret on the webhook, the body is signed with HMAC-SHA256; the signature verification post has the exact scheme and a working receiver.
Delivery guarantees you should assume
LayBuild's dispatcher makes one attempt. If your endpoint is down, slow, or returns a non-2xx status, the delivery is logged as failed and not sent again. There is no retry queue and no dead-letter queue. In delivery-semantics terms that is at most once.
Order is not guaranteed. LayBuild fires events without waiting for earlier ones to finish, and the first customer message of a conversation triggers CONVERSATION_CREATED and MESSAGE_RECEIVED back to back. Either can arrive first.
The same business event can be reported more than once. A conversation that is handed off and then picked up by a person produces two CONVERSATION_HANDOFF events (reasons ai_unable and then agent_claim). Conversations can be reopened after closing, so CONVERSATION_CLOSED can also appear more than once for one conversation. The webhook test action in the dashboard sends sample payloads to real endpoints too.
A few hard limits: payloads over 256 KB are dropped rather than sent, and the endpoint must be an http or https URL whose hostname resolves to a public address. LayBuild checks this before every delivery and skips endpoints that resolve to private or internal addresses, so the webhook feature can't be used to probe internal networks. A skipped delivery does not appear in the delivery log, so if an endpoint never shows any attempts, check its DNS first.
A receiver that tolerates all of that
The pattern is the same whatever you run downstream: verify, deduplicate, record, acknowledge, and do the real work later.
LayBuild API
| POST, one attempt, 5 s timeout
v
your receiver
- verify signature and timestamp
- skip if delivery ID already seen
- write raw event to a table or queue
- return 204
|
v
your workers (read from table/queue, retry on their own)
|-- CRM: upsert conversation by id
|-- alerts: page on-call on handoff
|-- warehouse: append messages for reportingAcknowledge fast. Five seconds includes your network and TLS setup, so do not call your CRM inside the request handler. Write the event somewhere durable and return.
Deduplicate on the delivery ID for protection against your own replays, but key your business logic on the conversation ID. Since the same handoff can be reported twice with different delivery IDs, "create a ticket in our tracker on handoff" should be "create one if this conversation doesn't have one yet".
Make every write an upsert with a monotonic check. For conversation state, keep the latest status and the timestamp you last applied, and ignore an event whose envelope timestamp is older. A late CONVERSATION_CREATED then cannot overwrite a conversation you already marked closed.
type ConversationStatus = 'OPEN' | 'HANDOFF' | 'CLOSED';
interface ConversationRow {
id: string;
status: ConversationStatus;
lastEventAt: string;
}
export function applyStatusEvent(
current: ConversationRow | undefined,
conversationId: string,
status: ConversationStatus,
eventTimestamp: string,
): ConversationRow {
// ISO 8601 UTC strings from the same source sort correctly as strings,
// but parsing makes a malformed timestamp fail loudly instead of silently.
const incoming = Date.parse(eventTimestamp);
if (Number.isNaN(incoming)) throw new Error('Bad event timestamp');
if (current && Date.parse(current.lastEventAt) > incoming) {
return current;
}
return { id: conversationId, status, lastEventAt: eventTimestamp };
}Treat events as hints, and reconcile. Because a failed delivery is gone, anything that must be complete (monthly reporting, billing your own customers per conversation) needs a periodic check against the source. LayBuild logs every delivery attempt with its status code, which tells you when you missed something.
Workflows these events can drive
Handoff alerts are the obvious first use. On CONVERSATION_HANDOFF with reason ai_unable or ai_turn_limit_reached, post to your team's chat or paging tool with a link to the conversation. Ignore agent_claim for alerting, since it means someone already has it.
Close-out records come next. On CONVERSATION_CLOSED, write the outcome to your CRM against the customer, including closedBy. Treat closedBy: "system" with inactivity_24h separately from a customer closing the chat; the first is silence, not satisfaction.
Keyword watch lists are simple to build on MESSAGE_RECEIVED with role USER: match terms like "cancel", "chargeback" or "legal" in your own code and notify an account owner. LayBuild does not do this itself.
A reporting table is the one we'd set up first. Append every event to a warehouse table and compute resolution, handoff rate and time to first human reply yourself. The resolution post describes which combinations of events count as resolved.
Why HTTP push and not a message bus
A broker like Kafka earns its keep when many consumers need the same stream, when consumers need to replay history, or when throughput is high enough that HTTP overhead matters. For a support product emitting four event types, the common case is one or two consumers per customer, and every customer can receive HTTP. So we push over HTTP and keep the sending side simple.
What that choice costs is replay and retries, and today LayBuild has neither. If you need them, put your own queue behind the receiver. The receiver's only job is to get the event into durable storage within the timeout; from there, your queue gives you retries, fan-out and replay on your terms.
Next steps
- Create a webhook in the dashboard, set a secret, and subscribe only to the events you will use. Plan limits apply: 10 webhooks on Starter, 250 on Pro, 600 on Premium.
- Build the receiver with signature verification first, then the queue, then the workflows.
- See the developer docs for the REST API.
