Webhooks
How BusinessChat pushes events to your systems the moment they happen: subscribing an endpoint, verifying what arrives, and diagnosing a delivery that did not land.
Overview#
A webhook is an event BusinessChat sends to the endpoint you configure, as it happens. Instead of polling the API asking "has anything changed?", you register an endpoint once and BusinessChat posts every event you selected to it.
Webhooks live under Settings → Developer settings → Webhooks. A business can register several, each with its own endpoint, signing secret, selection of events and enabled switch — so a fulfilment system and a CRM can subscribe independently and fail independently.
Every subscription carries five things:
POST with a JSON body.Authorization: Basic … header on
every delivery, alongside the signature — never instead of it.Creating a webhook#
Choose Create webhook. Only the name, the URL and at least one event are required.
Name#
A label for your own benefit, up to 200 characters. It appears in the list, in the events log, and in the notification you receive if the endpoint is ever auto-disabled — so name it after the system that consumes it, not after the events.
Endpoint URL#
Must be an https:// URL. In production the host must also resolve to a genuinely public address.
These are rejected:
- Loopback addresses (
127.0.0.0/8,::1) - Private and site-local ranges
- Link-local and multicast addresses
- The cloud metadata address
169.254.169.254
Redirects are not followed. If your endpoint answers 301 or
302, that is the response recorded — the Location header is not chased. Register the
final URL.
Basic authentication (optional)#
If your endpoint sits behind HTTP basic auth, expand this section and supply a username and password. They are
sent as an Authorization: Basic … header on every delivery — in addition to the signature, not
instead of it.
Choosing events#
Tick the objects you care about. Each expands to offer All actions — which keeps up automatically as new actions are added — or Specific actions, which pins the selection to exactly the ones you name.
work_item.created,
work_item.status_changed and so on. All actions is stored as
work_item.all.The signing secret#
Creating a webhook generates a signing secret prefixed whsec_. It is displayed
once and stored encrypted afterwards — no screen, endpoint or support process can show it to
you again.
If the secret is lost, the only recovery is to delete the subscription and create a new one, which issues a new secret. There is no rotation in place.
Events#
Events are selected as object.action and arrive with a camel-case event_name — the
work_item.created selection delivers events named onWorkItemCreated.
Selecting All actions stores conversation.all rather than the individual actions, so an
action added to that object in future starts arriving without you changing anything. If that is not what you
want, pin the selection with Specific actions.
Full catalogue#
Three objects, twenty-three events.
| Object | Actions |
|---|---|
conversation | opened, closed, assigned,
status_changed, rated, topic_added,
topic_deleted |
work_item | created, status_changed,
resolved, reopened, custom_field_changed, assigned,
commented, csat_added, sla_breached, sla_warning,
due_date_breached |
campaign | scheduled, sent, completed,
failed, cancelled |
What your endpoint receives#
Every delivery is a POST with Content-Type: application/json. The envelope is the
same for every event; only data changes shape.
The envelope#
| Field | Type | Description |
|---|---|---|
event_id | string | Identifies the occurrence. Stable across retries, and the same for every subscriber of that occurrence. |
object_id | The id of the thing the event is about — the session for a conversation event, the work item, the campaign. | |
event_name | string | The camel-case event name. |
object | string | The object the event is about — conversation, work_item, campaign. |
action | string | What happened to it. object and action together are what you selected. |
event_version | integer | Schema version of data. Currently 1 for every event. |
occurred_at | string · date-time | When the event happened — not when it was delivered. A retry an hour later carries the original timestamp. |
idempotency_key | string | Unique per subscription per occurrence, prefixed bc_whd_. This is the key to deduplicate on. |
data | object | The event payload. Its shape depends on the object — see Payload by event. |
business_id | string | The business the event belongs to. Constant for one subscription, and the value to route on when one endpoint serves several businesses. |
delta | object | Present only where a transition is meaningful — conversation.status_changed carries the status it moved to. Absent otherwise. |
Headers#
| Header | Contents |
|---|---|
X-Bc-Signature | HMAC-SHA256 of the signed payload, hex encoded. |
X-Bc-Timestamp | Unix epoch seconds at which the request was signed. |
X-Bc-Event | The event name, matching event_name in the body. |
X-Bc-Event-Version | The schema version, matching event_version. |
X-Bc-Delivery-Id | Identifies this individual attempt. Differs between retries of the same event. |
X-Bc-Idempotency-Key | Matches idempotency_key in the body. |
Authorization | Only if you configured basic auth. |
Payload by event#
One entry per event: the fields it carries, and the exact JSON we post. The envelope is the same every
time — only data and delta change shape — so it is described once in
The envelope, and the request headers in Headers.
Values are illustrative. Ids, timestamps and phone numbers are examples, not fixtures to assert against —
match on event_name and the field shapes, and treat unknown fields as additive.
Conversation#
conversation
A chat with a contact, from the moment it opens to the moment it closes.
7 events
onConversationOpened
A conversation started.
conversation.opened
| Field | Type | Description |
|---|---|---|
data.session_id | string | The session this event is about. |
data.conversation_id | string | The conversation the session belongs to. |
data.started_at | string · date-time | When the session opened. |
data.ended_at | string · date-time · null | When the session closed. null while it is open. |
data.ended_by | string · null | Who closed the session — user, contact, bot, system. |
data.initiated_by | string | Who opened the session. |
data.delegation_mode | string | How the session is delegated. |
data.escalation_count | integer | How many times the session was escalated. |
data.conversation_session_status | string | open, closed or escalated. |
data.contact | object | The contact profile, resolved in full. |
data.actor | object | Who caused the event, with the user or team resolved. |
data.assignor | object · null | Who made the assignment, with user and team resolved. |
data.assignee | object · null | Who the session is assigned to. Falls back to the conversation when the assignment was made in an earlier session. |
data.topics | array | Every topic on the session, with who added it and when. |
data.messages | array | The session transcript, oldest first. |
data.events | array | The session's events, oldest first. |
onConversationClosed
A conversation was closed.
conversation.closed
| Field | Type | Description |
|---|---|---|
data.session_id | string | The session this event is about. |
data.conversation_id | string | The conversation the session belongs to. |
data.started_at | string · date-time | When the session opened. |
data.ended_at | string · date-time · null | When the session closed. null while it is open. |
data.ended_by | string · null | Who closed the session — user, contact, bot, system. |
data.initiated_by | string | Who opened the session. |
data.delegation_mode | string | How the session is delegated. |
data.escalation_count | integer | How many times the session was escalated. |
data.conversation_session_status | string | open, closed or escalated. |
data.contact | object | The contact profile, resolved in full. |
data.actor | object | Who caused the event, with the user or team resolved. |
data.assignor | object · null | Who made the assignment, with user and team resolved. |
data.assignee | object · null | Who the session is assigned to. Falls back to the conversation when the assignment was made in an earlier session. |
data.topics | array | Every topic on the session, with who added it and when. |
data.messages | array | The session transcript, oldest first. |
data.events | array | The session's events, oldest first. |
onConversationAssigned
A conversation was assigned.
conversation.assigned
| Field | Type | Description |
|---|---|---|
data.session_id | string | The session this event is about. |
data.conversation_id | string | The conversation the session belongs to. |
data.started_at | string · date-time | When the session opened. |
data.ended_at | string · date-time · null | When the session closed. null while it is open. |
data.ended_by | string · null | Who closed the session — user, contact, bot, system. |
data.initiated_by | string | Who opened the session. |
data.delegation_mode | string | How the session is delegated. |
data.escalation_count | integer | How many times the session was escalated. |
data.conversation_session_status | string | open, closed or escalated. |
data.contact | object | The contact profile, resolved in full. |
data.actor | object | Who caused the event, with the user or team resolved. |
data.assignor | object · null | Who made the assignment, with user and team resolved. |
data.assignee | object · null | Who the session is assigned to. Falls back to the conversation when the assignment was made in an earlier session. |
data.topics | array | Every topic on the session, with who added it and when. |
data.messages | array | The session transcript, oldest first. |
data.events | array | The session's events, oldest first. |
onConversationStatusChanged
A conversation was escalated, changing its status.
conversation.status_changed
| Field | Type | Description |
|---|---|---|
data.session_id | string | The session this event is about. |
data.conversation_id | string | The conversation the session belongs to. |
data.started_at | string · date-time | When the session opened. |
data.ended_at | string · date-time · null | When the session closed. null while it is open. |
data.ended_by | string · null | Who closed the session — user, contact, bot, system. |
data.initiated_by | string | Who opened the session. |
data.delegation_mode | string | How the session is delegated. |
data.escalation_count | integer | How many times the session was escalated. |
data.conversation_session_status | string | open, closed or escalated. |
data.contact | object | The contact profile, resolved in full. |
data.actor | object | Who caused the event, with the user or team resolved. |
data.assignor | object · null | Who made the assignment, with user and team resolved. |
data.assignee | object · null | Who the session is assigned to. Falls back to the conversation when the assignment was made in an earlier session. |
data.topics | array | Every topic on the session, with who added it and when. |
data.messages | array | The session transcript, oldest first. |
data.events | array | The session's events, oldest first. |
onConversationRated
A contact rated a conversation.
conversation.rated
| Field | Type | Description |
|---|---|---|
data.session_id | string | The session this event is about. |
data.conversation_id | string | The conversation the session belongs to. |
data.started_at | string · date-time | When the session opened. |
data.ended_at | string · date-time · null | When the session closed. null while it is open. |
data.ended_by | string · null | Who closed the session — user, contact, bot, system. |
data.initiated_by | string | Who opened the session. |
data.delegation_mode | string | How the session is delegated. |
data.escalation_count | integer | How many times the session was escalated. |
data.conversation_session_status | string | open, closed or escalated. |
data.contact | object | The contact profile, resolved in full. |
data.actor | object | Who caused the event, with the user or team resolved. |
data.assignor | object · null | Who made the assignment, with user and team resolved. |
data.assignee | object · null | Who the session is assigned to. Falls back to the conversation when the assignment was made in an earlier session. |
data.topics | array | Every topic on the session, with who added it and when. |
data.messages | array | The session transcript, oldest first. |
data.events | array | The session's events, oldest first. |
onConversationTopicAdded
A topic was added to a conversation.
conversation.topic_added
| Field | Type | Description |
|---|---|---|
data.session_id | string | The session this event is about. |
data.conversation_id | string | The conversation the session belongs to. |
data.started_at | string · date-time | When the session opened. |
data.ended_at | string · date-time · null | When the session closed. null while it is open. |
data.ended_by | string · null | Who closed the session — user, contact, bot, system. |
data.initiated_by | string | Who opened the session. |
data.delegation_mode | string | How the session is delegated. |
data.escalation_count | integer | How many times the session was escalated. |
data.conversation_session_status | string | open, closed or escalated. |
data.contact | object | The contact profile, resolved in full. |
data.actor | object | Who caused the event, with the user or team resolved. |
data.assignor | object · null | Who made the assignment, with user and team resolved. |
data.assignee | object · null | Who the session is assigned to. Falls back to the conversation when the assignment was made in an earlier session. |
data.topics | array | Every topic on the session, with who added it and when. |
data.messages | array | The session transcript, oldest first. |
data.events | array | The session's events, oldest first. |
onConversationTopicDeleted
A topic was removed from a conversation.
conversation.topic_deleted
| Field | Type | Description |
|---|---|---|
data.session_id | string | The session this event is about. |
data.conversation_id | string | The conversation the session belongs to. |
data.started_at | string · date-time | When the session opened. |
data.ended_at | string · date-time · null | When the session closed. null while it is open. |
data.ended_by | string · null | Who closed the session — user, contact, bot, system. |
data.initiated_by | string | Who opened the session. |
data.delegation_mode | string | How the session is delegated. |
data.escalation_count | integer | How many times the session was escalated. |
data.conversation_session_status | string | open, closed or escalated. |
data.contact | object | The contact profile, resolved in full. |
data.actor | object | Who caused the event, with the user or team resolved. |
data.assignor | object · null | Who made the assignment, with user and team resolved. |
data.assignee | object · null | Who the session is assigned to. Falls back to the conversation when the assignment was made in an earlier session. |
data.topics | array | Every topic on the session, with who added it and when. |
data.messages | array | The session transcript, oldest first. |
data.events | array | The session's events, oldest first. |
Work Item#
work_item
Tickets, tasks and incidents — their lifecycle, assignment, SLA and CSAT.
11 events
onWorkItemCreated
A work item was created.
work_item.created
| Field | Type | Description |
|---|---|---|
data.id | string | Work item id. |
data.business_id | string | Business id. |
data.public_reference | string | The reference shown in the UI, e.g. TKT-1042. |
data.kind | string | ticket, task or incident. |
data.config_id | string | Id of the type the item was created from; see data.type. |
data.current_status_id | string, nullable | Id of the current status; see data.status. |
data.current_stage | string | submitted, in_progress, waiting_on_customer, resolved, cancelled. |
data.title | string | Title. |
data.description | string | Description, may be empty. |
data.contact_id | string, nullable | Primary contact id. |
data.conversation_id | string, nullable | Linked conversation id. |
data.conversation_session_id | string, nullable | Linked session id. |
data.creator_user_id, data.creator_team_id | string, nullable | Who created it. |
data.assignee_user_id, data.assignee_team_id | string, nullable | Current assignee. |
data.assigned_by_user_id, data.assigned_at | string, nullable | Last assignment. |
data.creation_channel | string | conversation, manual, api. |
data.severity | string, nullable | low, medium, high, critical. |
data.due_at | string, nullable | Due date, ISO-8601. |
data.reopen_count | integer | Times reopened. |
data.last_activity_at, data.activity_count | string / integer | Activity. |
data.version | integer | Optimistic-lock version. |
data.sla_target_ms, data.sla_calendar_snapshot, data.sla_started_at, data.sla_deadline_at, data.sla_state, data.sla_last_paused_at, data.sla_total_paused_ms, data.sla_breached_at, data.sla_risk_level | mixed, nullable | The SLA clock. sla_calendar_snapshot is free-form JSON as stored; keys are not part of the stable contract. |
data.submitted_at, data.in_progress_at, data.waiting_on_customer_at, data.resolved_at, data.cancelled_at | string, nullable | When each stage was first entered. |
data.created_at, data.updated_at | string | Row timestamps. |
data.type | object, nullable | The work item type: id, business_id, kind, name, emoji, description, resolved_template_name, resolved_template_languages, is_archived, archived_at, version, sla_target_ms, sla_calendar_id, created_at, updated_at. |
data.status | object, nullable | The current status: id, business_id, parent_stage, label, internal_label, sort_order, assignment_mode, owning_team_id, is_archived, archived_at, version, created_at, updated_at. |
data.contact | object, nullable | The linked conversation's profile, same shape as data.contact on conversation events. Null when the item has no conversation. |
data.comments | array | Every non-deleted comment in time order: id, business_id, work_item_id, author_user_id, author (user object, same shape as on conversation events, or null), body, attachments (free-form JSON as stored; keys are not part of the stable contract), mentions, created_at, edited_at, deleted_at. |
data.actor | object | type is user or system; user is the user object when type is user. |
onWorkItemStatusChanged
A work item moved to another stage.
work_item.status_changed
| Field | Type | Description |
|---|---|---|
data.id | string | Work item id. |
data.business_id | string | Business id. |
data.public_reference | string | The reference shown in the UI, e.g. TKT-1042. |
data.kind | string | ticket, task or incident. |
data.config_id | string | Id of the type the item was created from; see data.type. |
data.current_status_id | string, nullable | Id of the current status; see data.status. |
data.current_stage | string | submitted, in_progress, waiting_on_customer, resolved, cancelled. |
data.title | string | Title. |
data.description | string | Description, may be empty. |
data.contact_id | string, nullable | Primary contact id. |
data.conversation_id | string, nullable | Linked conversation id. |
data.conversation_session_id | string, nullable | Linked session id. |
data.creator_user_id, data.creator_team_id | string, nullable | Who created it. |
data.assignee_user_id, data.assignee_team_id | string, nullable | Current assignee. |
data.assigned_by_user_id, data.assigned_at | string, nullable | Last assignment. |
data.creation_channel | string | conversation, manual, api. |
data.severity | string, nullable | low, medium, high, critical. |
data.due_at | string, nullable | Due date, ISO-8601. |
data.reopen_count | integer | Times reopened. |
data.last_activity_at, data.activity_count | string / integer | Activity. |
data.version | integer | Optimistic-lock version. |
data.sla_target_ms, data.sla_calendar_snapshot, data.sla_started_at, data.sla_deadline_at, data.sla_state, data.sla_last_paused_at, data.sla_total_paused_ms, data.sla_breached_at, data.sla_risk_level | mixed, nullable | The SLA clock. sla_calendar_snapshot is free-form JSON as stored; keys are not part of the stable contract. |
data.submitted_at, data.in_progress_at, data.waiting_on_customer_at, data.resolved_at, data.cancelled_at | string, nullable | When each stage was first entered. |
data.created_at, data.updated_at | string | Row timestamps. |
data.type | object, nullable | The work item type: id, business_id, kind, name, emoji, description, resolved_template_name, resolved_template_languages, is_archived, archived_at, version, sla_target_ms, sla_calendar_id, created_at, updated_at. |
data.status | object, nullable | The current status: id, business_id, parent_stage, label, internal_label, sort_order, assignment_mode, owning_team_id, is_archived, archived_at, version, created_at, updated_at. |
data.contact | object, nullable | The linked conversation's profile, same shape as data.contact on conversation events. Null when the item has no conversation. |
data.comments | array | Every non-deleted comment in time order: id, business_id, work_item_id, author_user_id, author (user object, same shape as on conversation events, or null), body, attachments (free-form JSON as stored; keys are not part of the stable contract), mentions, created_at, edited_at, deleted_at. |
data.actor | object | type is user or system; user is the user object when type is user. |
delta | object | Present only where a transition is meaningful. Holds one key for each field the transition changed, so one event can carry several fields. Absent otherwise. |
delta.from | object | Values before the change. null when there is no prior state to report. |
delta.from.stage | string | Stage it moved from. |
delta.to | object | Values after the change. |
delta.to.stage | string | Stage it moved to. |
onWorkItemResolved
A work item was resolved.
work_item.resolved
| Field | Type | Description |
|---|---|---|
data.id | string | Work item id. |
data.business_id | string | Business id. |
data.public_reference | string | The reference shown in the UI, e.g. TKT-1042. |
data.kind | string | ticket, task or incident. |
data.config_id | string | Id of the type the item was created from; see data.type. |
data.current_status_id | string, nullable | Id of the current status; see data.status. |
data.current_stage | string | submitted, in_progress, waiting_on_customer, resolved, cancelled. |
data.title | string | Title. |
data.description | string | Description, may be empty. |
data.contact_id | string, nullable | Primary contact id. |
data.conversation_id | string, nullable | Linked conversation id. |
data.conversation_session_id | string, nullable | Linked session id. |
data.creator_user_id, data.creator_team_id | string, nullable | Who created it. |
data.assignee_user_id, data.assignee_team_id | string, nullable | Current assignee. |
data.assigned_by_user_id, data.assigned_at | string, nullable | Last assignment. |
data.creation_channel | string | conversation, manual, api. |
data.severity | string, nullable | low, medium, high, critical. |
data.due_at | string, nullable | Due date, ISO-8601. |
data.reopen_count | integer | Times reopened. |
data.last_activity_at, data.activity_count | string / integer | Activity. |
data.version | integer | Optimistic-lock version. |
data.sla_target_ms, data.sla_calendar_snapshot, data.sla_started_at, data.sla_deadline_at, data.sla_state, data.sla_last_paused_at, data.sla_total_paused_ms, data.sla_breached_at, data.sla_risk_level | mixed, nullable | The SLA clock. sla_calendar_snapshot is free-form JSON as stored; keys are not part of the stable contract. |
data.submitted_at, data.in_progress_at, data.waiting_on_customer_at, data.resolved_at, data.cancelled_at | string, nullable | When each stage was first entered. |
data.created_at, data.updated_at | string | Row timestamps. |
data.type | object, nullable | The work item type: id, business_id, kind, name, emoji, description, resolved_template_name, resolved_template_languages, is_archived, archived_at, version, sla_target_ms, sla_calendar_id, created_at, updated_at. |
data.status | object, nullable | The current status: id, business_id, parent_stage, label, internal_label, sort_order, assignment_mode, owning_team_id, is_archived, archived_at, version, created_at, updated_at. |
data.contact | object, nullable | The linked conversation's profile, same shape as data.contact on conversation events. Null when the item has no conversation. |
data.comments | array | Every non-deleted comment in time order: id, business_id, work_item_id, author_user_id, author (user object, same shape as on conversation events, or null), body, attachments (free-form JSON as stored; keys are not part of the stable contract), mentions, created_at, edited_at, deleted_at. |
data.actor | object | type is user or system; user is the user object when type is user. |
delta | object | Present only where a transition is meaningful. Holds one key for each field the transition changed, so one event can carry several fields. Absent otherwise. |
delta.from | object | Values before the change. null when there is no prior state to report. |
delta.from.stage | string | Stage it moved from. |
delta.to | object | Values after the change. |
delta.to.stage | string | Stage it moved to. |
onWorkItemReopened
A resolved work item was reopened.
work_item.reopened
| Field | Type | Description |
|---|---|---|
data.id | string | Work item id. |
data.business_id | string | Business id. |
data.public_reference | string | The reference shown in the UI, e.g. TKT-1042. |
data.kind | string | ticket, task or incident. |
data.config_id | string | Id of the type the item was created from; see data.type. |
data.current_status_id | string, nullable | Id of the current status; see data.status. |
data.current_stage | string | submitted, in_progress, waiting_on_customer, resolved, cancelled. |
data.title | string | Title. |
data.description | string | Description, may be empty. |
data.contact_id | string, nullable | Primary contact id. |
data.conversation_id | string, nullable | Linked conversation id. |
data.conversation_session_id | string, nullable | Linked session id. |
data.creator_user_id, data.creator_team_id | string, nullable | Who created it. |
data.assignee_user_id, data.assignee_team_id | string, nullable | Current assignee. |
data.assigned_by_user_id, data.assigned_at | string, nullable | Last assignment. |
data.creation_channel | string | conversation, manual, api. |
data.severity | string, nullable | low, medium, high, critical. |
data.due_at | string, nullable | Due date, ISO-8601. |
data.reopen_count | integer | Times reopened. |
data.last_activity_at, data.activity_count | string / integer | Activity. |
data.version | integer | Optimistic-lock version. |
data.sla_target_ms, data.sla_calendar_snapshot, data.sla_started_at, data.sla_deadline_at, data.sla_state, data.sla_last_paused_at, data.sla_total_paused_ms, data.sla_breached_at, data.sla_risk_level | mixed, nullable | The SLA clock. sla_calendar_snapshot is free-form JSON as stored; keys are not part of the stable contract. |
data.submitted_at, data.in_progress_at, data.waiting_on_customer_at, data.resolved_at, data.cancelled_at | string, nullable | When each stage was first entered. |
data.created_at, data.updated_at | string | Row timestamps. |
data.type | object, nullable | The work item type: id, business_id, kind, name, emoji, description, resolved_template_name, resolved_template_languages, is_archived, archived_at, version, sla_target_ms, sla_calendar_id, created_at, updated_at. |
data.status | object, nullable | The current status: id, business_id, parent_stage, label, internal_label, sort_order, assignment_mode, owning_team_id, is_archived, archived_at, version, created_at, updated_at. |
data.contact | object, nullable | The linked conversation's profile, same shape as data.contact on conversation events. Null when the item has no conversation. |
data.comments | array | Every non-deleted comment in time order: id, business_id, work_item_id, author_user_id, author (user object, same shape as on conversation events, or null), body, attachments (free-form JSON as stored; keys are not part of the stable contract), mentions, created_at, edited_at, deleted_at. |
data.actor | object | type is user or system; user is the user object when type is user. |
delta | object | Present only where a transition is meaningful. Holds one key for each field the transition changed, so one event can carry several fields. Absent otherwise. |
delta.from | object | Values before the change. null when there is no prior state to report. |
delta.from.stage | string | Stage it moved from. |
delta.to | object | Values after the change. |
delta.to.stage | string | Stage it moved to. |
onWorkItemAssigned
A work item was assigned to someone else.
work_item.assigned
| Field | Type | Description |
|---|---|---|
data.id | string | Work item id. |
data.business_id | string | Business id. |
data.public_reference | string | The reference shown in the UI, e.g. TKT-1042. |
data.kind | string | ticket, task or incident. |
data.config_id | string | Id of the type the item was created from; see data.type. |
data.current_status_id | string, nullable | Id of the current status; see data.status. |
data.current_stage | string | submitted, in_progress, waiting_on_customer, resolved, cancelled. |
data.title | string | Title. |
data.description | string | Description, may be empty. |
data.contact_id | string, nullable | Primary contact id. |
data.conversation_id | string, nullable | Linked conversation id. |
data.conversation_session_id | string, nullable | Linked session id. |
data.creator_user_id, data.creator_team_id | string, nullable | Who created it. |
data.assignee_user_id, data.assignee_team_id | string, nullable | Current assignee. |
data.assigned_by_user_id, data.assigned_at | string, nullable | Last assignment. |
data.creation_channel | string | conversation, manual, api. |
data.severity | string, nullable | low, medium, high, critical. |
data.due_at | string, nullable | Due date, ISO-8601. |
data.reopen_count | integer | Times reopened. |
data.last_activity_at, data.activity_count | string / integer | Activity. |
data.version | integer | Optimistic-lock version. |
data.sla_target_ms, data.sla_calendar_snapshot, data.sla_started_at, data.sla_deadline_at, data.sla_state, data.sla_last_paused_at, data.sla_total_paused_ms, data.sla_breached_at, data.sla_risk_level | mixed, nullable | The SLA clock. sla_calendar_snapshot is free-form JSON as stored; keys are not part of the stable contract. |
data.submitted_at, data.in_progress_at, data.waiting_on_customer_at, data.resolved_at, data.cancelled_at | string, nullable | When each stage was first entered. |
data.created_at, data.updated_at | string | Row timestamps. |
data.type | object, nullable | The work item type: id, business_id, kind, name, emoji, description, resolved_template_name, resolved_template_languages, is_archived, archived_at, version, sla_target_ms, sla_calendar_id, created_at, updated_at. |
data.status | object, nullable | The current status: id, business_id, parent_stage, label, internal_label, sort_order, assignment_mode, owning_team_id, is_archived, archived_at, version, created_at, updated_at. |
data.contact | object, nullable | The linked conversation's profile, same shape as data.contact on conversation events. Null when the item has no conversation. |
data.comments | array | Every non-deleted comment in time order: id, business_id, work_item_id, author_user_id, author (user object, same shape as on conversation events, or null), body, attachments (free-form JSON as stored; keys are not part of the stable contract), mentions, created_at, edited_at, deleted_at. |
data.actor | object | type is user or system; user is the user object when type is user. |
delta | object | Present only where a transition is meaningful. Holds one key for each field the transition changed, so one event can carry several fields. Absent otherwise. |
delta.from | object | Values before the change. null when there is no prior state to report. |
delta.from.assignee_user_id | string | User it was assigned to before. |
delta.to | object | Values after the change. |
delta.to.assignee_user_id | string | User it is assigned to now. |
onWorkItemCommented
A comment was added to a work item.
work_item.commented
| Field | Type | Description |
|---|---|---|
data.id | string | Work item id. |
data.business_id | string | Business id. |
data.public_reference | string | The reference shown in the UI, e.g. TKT-1042. |
data.kind | string | ticket, task or incident. |
data.config_id | string | Id of the type the item was created from; see data.type. |
data.current_status_id | string, nullable | Id of the current status; see data.status. |
data.current_stage | string | submitted, in_progress, waiting_on_customer, resolved, cancelled. |
data.title | string | Title. |
data.description | string | Description, may be empty. |
data.contact_id | string, nullable | Primary contact id. |
data.conversation_id | string, nullable | Linked conversation id. |
data.conversation_session_id | string, nullable | Linked session id. |
data.creator_user_id, data.creator_team_id | string, nullable | Who created it. |
data.assignee_user_id, data.assignee_team_id | string, nullable | Current assignee. |
data.assigned_by_user_id, data.assigned_at | string, nullable | Last assignment. |
data.creation_channel | string | conversation, manual, api. |
data.severity | string, nullable | low, medium, high, critical. |
data.due_at | string, nullable | Due date, ISO-8601. |
data.reopen_count | integer | Times reopened. |
data.last_activity_at, data.activity_count | string / integer | Activity. |
data.version | integer | Optimistic-lock version. |
data.sla_target_ms, data.sla_calendar_snapshot, data.sla_started_at, data.sla_deadline_at, data.sla_state, data.sla_last_paused_at, data.sla_total_paused_ms, data.sla_breached_at, data.sla_risk_level | mixed, nullable | The SLA clock. sla_calendar_snapshot is free-form JSON as stored; keys are not part of the stable contract. |
data.submitted_at, data.in_progress_at, data.waiting_on_customer_at, data.resolved_at, data.cancelled_at | string, nullable | When each stage was first entered. |
data.created_at, data.updated_at | string | Row timestamps. |
data.type | object, nullable | The work item type: id, business_id, kind, name, emoji, description, resolved_template_name, resolved_template_languages, is_archived, archived_at, version, sla_target_ms, sla_calendar_id, created_at, updated_at. |
data.status | object, nullable | The current status: id, business_id, parent_stage, label, internal_label, sort_order, assignment_mode, owning_team_id, is_archived, archived_at, version, created_at, updated_at. |
data.contact | object, nullable | The linked conversation's profile, same shape as data.contact on conversation events. Null when the item has no conversation. |
data.comments | array | Every non-deleted comment in time order: id, business_id, work_item_id, author_user_id, author (user object, same shape as on conversation events, or null), body, attachments (free-form JSON as stored; keys are not part of the stable contract), mentions, created_at, edited_at, deleted_at. |
data.actor | object | type is user or system; user is the user object when type is user. |
onWorkItemCustomFieldChanged
A custom field on a work item changed value.
work_item.custom_field_changed
| Field | Type | Description |
|---|---|---|
data.id | string | Work item id. |
data.business_id | string | Business id. |
data.public_reference | string | The reference shown in the UI, e.g. TKT-1042. |
data.kind | string | ticket, task or incident. |
data.config_id | string | Id of the type the item was created from; see data.type. |
data.current_status_id | string, nullable | Id of the current status; see data.status. |
data.current_stage | string | submitted, in_progress, waiting_on_customer, resolved, cancelled. |
data.title | string | Title. |
data.description | string | Description, may be empty. |
data.contact_id | string, nullable | Primary contact id. |
data.conversation_id | string, nullable | Linked conversation id. |
data.conversation_session_id | string, nullable | Linked session id. |
data.creator_user_id, data.creator_team_id | string, nullable | Who created it. |
data.assignee_user_id, data.assignee_team_id | string, nullable | Current assignee. |
data.assigned_by_user_id, data.assigned_at | string, nullable | Last assignment. |
data.creation_channel | string | conversation, manual, api. |
data.severity | string, nullable | low, medium, high, critical. |
data.due_at | string, nullable | Due date, ISO-8601. |
data.reopen_count | integer | Times reopened. |
data.last_activity_at, data.activity_count | string / integer | Activity. |
data.version | integer | Optimistic-lock version. |
data.sla_target_ms, data.sla_calendar_snapshot, data.sla_started_at, data.sla_deadline_at, data.sla_state, data.sla_last_paused_at, data.sla_total_paused_ms, data.sla_breached_at, data.sla_risk_level | mixed, nullable | The SLA clock. sla_calendar_snapshot is free-form JSON as stored; keys are not part of the stable contract. |
data.submitted_at, data.in_progress_at, data.waiting_on_customer_at, data.resolved_at, data.cancelled_at | string, nullable | When each stage was first entered. |
data.created_at, data.updated_at | string | Row timestamps. |
data.type | object, nullable | The work item type: id, business_id, kind, name, emoji, description, resolved_template_name, resolved_template_languages, is_archived, archived_at, version, sla_target_ms, sla_calendar_id, created_at, updated_at. |
data.status | object, nullable | The current status: id, business_id, parent_stage, label, internal_label, sort_order, assignment_mode, owning_team_id, is_archived, archived_at, version, created_at, updated_at. |
data.contact | object, nullable | The linked conversation's profile, same shape as data.contact on conversation events. Null when the item has no conversation. |
data.comments | array | Every non-deleted comment in time order: id, business_id, work_item_id, author_user_id, author (user object, same shape as on conversation events, or null), body, attachments (free-form JSON as stored; keys are not part of the stable contract), mentions, created_at, edited_at, deleted_at. |
data.actor | object | type is user or system; user is the user object when type is user. |
delta | object | Present only where a transition is meaningful. Holds one key for each field the transition changed, so one event can carry several fields. Absent otherwise. |
delta.from | object | Values before the change. null when there is no prior state to report. |
delta.from.{custom_field_id} | string | null | Value before, keyed by the custom field’s id. |
delta.to | object | Values after the change. |
delta.to.{custom_field_id} | string | Value now, keyed by the same custom field id. |
onWorkItemSlaWarning
A work item is approaching its SLA deadline.
work_item.sla_warning
| Field | Type | Description |
|---|---|---|
data.id | string | Work item id. |
data.business_id | string | Business id. |
data.public_reference | string | The reference shown in the UI, e.g. TKT-1042. |
data.kind | string | ticket, task or incident. |
data.config_id | string | Id of the type the item was created from; see data.type. |
data.current_status_id | string, nullable | Id of the current status; see data.status. |
data.current_stage | string | submitted, in_progress, waiting_on_customer, resolved, cancelled. |
data.title | string | Title. |
data.description | string | Description, may be empty. |
data.contact_id | string, nullable | Primary contact id. |
data.conversation_id | string, nullable | Linked conversation id. |
data.conversation_session_id | string, nullable | Linked session id. |
data.creator_user_id, data.creator_team_id | string, nullable | Who created it. |
data.assignee_user_id, data.assignee_team_id | string, nullable | Current assignee. |
data.assigned_by_user_id, data.assigned_at | string, nullable | Last assignment. |
data.creation_channel | string | conversation, manual, api. |
data.severity | string, nullable | low, medium, high, critical. |
data.due_at | string, nullable | Due date, ISO-8601. |
data.reopen_count | integer | Times reopened. |
data.last_activity_at, data.activity_count | string / integer | Activity. |
data.version | integer | Optimistic-lock version. |
data.sla_target_ms, data.sla_calendar_snapshot, data.sla_started_at, data.sla_deadline_at, data.sla_state, data.sla_last_paused_at, data.sla_total_paused_ms, data.sla_breached_at, data.sla_risk_level | mixed, nullable | The SLA clock. sla_calendar_snapshot is free-form JSON as stored; keys are not part of the stable contract. |
data.submitted_at, data.in_progress_at, data.waiting_on_customer_at, data.resolved_at, data.cancelled_at | string, nullable | When each stage was first entered. |
data.created_at, data.updated_at | string | Row timestamps. |
data.type | object, nullable | The work item type: id, business_id, kind, name, emoji, description, resolved_template_name, resolved_template_languages, is_archived, archived_at, version, sla_target_ms, sla_calendar_id, created_at, updated_at. |
data.status | object, nullable | The current status: id, business_id, parent_stage, label, internal_label, sort_order, assignment_mode, owning_team_id, is_archived, archived_at, version, created_at, updated_at. |
data.contact | object, nullable | The linked conversation's profile, same shape as data.contact on conversation events. Null when the item has no conversation. |
data.comments | array | Every non-deleted comment in time order: id, business_id, work_item_id, author_user_id, author (user object, same shape as on conversation events, or null), body, attachments (free-form JSON as stored; keys are not part of the stable contract), mentions, created_at, edited_at, deleted_at. |
data.actor | object | type is user or system; user is the user object when type is user. |
delta | object | Present only where a transition is meaningful. Holds one key for each field the transition changed, so one event can carry several fields. Absent otherwise. |
delta.from | object | Values before the change. null when there is no prior state to report. |
delta.from.sla_risk_level | string | Risk level before. |
delta.to | object | Values after the change. |
delta.to.sla_risk_level | string | Risk level now. |
onWorkItemSlaBreached
A work item passed its SLA deadline.
work_item.sla_breached
| Field | Type | Description |
|---|---|---|
data.id | string | Work item id. |
data.business_id | string | Business id. |
data.public_reference | string | The reference shown in the UI, e.g. TKT-1042. |
data.kind | string | ticket, task or incident. |
data.config_id | string | Id of the type the item was created from; see data.type. |
data.current_status_id | string, nullable | Id of the current status; see data.status. |
data.current_stage | string | submitted, in_progress, waiting_on_customer, resolved, cancelled. |
data.title | string | Title. |
data.description | string | Description, may be empty. |
data.contact_id | string, nullable | Primary contact id. |
data.conversation_id | string, nullable | Linked conversation id. |
data.conversation_session_id | string, nullable | Linked session id. |
data.creator_user_id, data.creator_team_id | string, nullable | Who created it. |
data.assignee_user_id, data.assignee_team_id | string, nullable | Current assignee. |
data.assigned_by_user_id, data.assigned_at | string, nullable | Last assignment. |
data.creation_channel | string | conversation, manual, api. |
data.severity | string, nullable | low, medium, high, critical. |
data.due_at | string, nullable | Due date, ISO-8601. |
data.reopen_count | integer | Times reopened. |
data.last_activity_at, data.activity_count | string / integer | Activity. |
data.version | integer | Optimistic-lock version. |
data.sla_target_ms, data.sla_calendar_snapshot, data.sla_started_at, data.sla_deadline_at, data.sla_state, data.sla_last_paused_at, data.sla_total_paused_ms, data.sla_breached_at, data.sla_risk_level | mixed, nullable | The SLA clock. sla_calendar_snapshot is free-form JSON as stored; keys are not part of the stable contract. |
data.submitted_at, data.in_progress_at, data.waiting_on_customer_at, data.resolved_at, data.cancelled_at | string, nullable | When each stage was first entered. |
data.created_at, data.updated_at | string | Row timestamps. |
data.type | object, nullable | The work item type: id, business_id, kind, name, emoji, description, resolved_template_name, resolved_template_languages, is_archived, archived_at, version, sla_target_ms, sla_calendar_id, created_at, updated_at. |
data.status | object, nullable | The current status: id, business_id, parent_stage, label, internal_label, sort_order, assignment_mode, owning_team_id, is_archived, archived_at, version, created_at, updated_at. |
data.contact | object, nullable | The linked conversation's profile, same shape as data.contact on conversation events. Null when the item has no conversation. |
data.comments | array | Every non-deleted comment in time order: id, business_id, work_item_id, author_user_id, author (user object, same shape as on conversation events, or null), body, attachments (free-form JSON as stored; keys are not part of the stable contract), mentions, created_at, edited_at, deleted_at. |
data.actor | object | type is user or system; user is the user object when type is user. |
delta | object | Present only where a transition is meaningful. Holds one key for each field the transition changed, so one event can carry several fields. Absent otherwise. |
delta.from | object | Values before the change. null when there is no prior state to report. |
delta.from.sla_state | string | SLA state before. |
delta.to | object | Values after the change. |
delta.to.sla_state | string | SLA state now. |
onWorkItemDueDateBreached
A work item passed its due date.
work_item.due_date_breached
| Field | Type | Description |
|---|---|---|
data.id | string | Work item id. |
data.business_id | string | Business id. |
data.public_reference | string | The reference shown in the UI, e.g. TKT-1042. |
data.kind | string | ticket, task or incident. |
data.config_id | string | Id of the type the item was created from; see data.type. |
data.current_status_id | string, nullable | Id of the current status; see data.status. |
data.current_stage | string | submitted, in_progress, waiting_on_customer, resolved, cancelled. |
data.title | string | Title. |
data.description | string | Description, may be empty. |
data.contact_id | string, nullable | Primary contact id. |
data.conversation_id | string, nullable | Linked conversation id. |
data.conversation_session_id | string, nullable | Linked session id. |
data.creator_user_id, data.creator_team_id | string, nullable | Who created it. |
data.assignee_user_id, data.assignee_team_id | string, nullable | Current assignee. |
data.assigned_by_user_id, data.assigned_at | string, nullable | Last assignment. |
data.creation_channel | string | conversation, manual, api. |
data.severity | string, nullable | low, medium, high, critical. |
data.due_at | string, nullable | Due date, ISO-8601. |
data.reopen_count | integer | Times reopened. |
data.last_activity_at, data.activity_count | string / integer | Activity. |
data.version | integer | Optimistic-lock version. |
data.sla_target_ms, data.sla_calendar_snapshot, data.sla_started_at, data.sla_deadline_at, data.sla_state, data.sla_last_paused_at, data.sla_total_paused_ms, data.sla_breached_at, data.sla_risk_level | mixed, nullable | The SLA clock. sla_calendar_snapshot is free-form JSON as stored; keys are not part of the stable contract. |
data.submitted_at, data.in_progress_at, data.waiting_on_customer_at, data.resolved_at, data.cancelled_at | string, nullable | When each stage was first entered. |
data.created_at, data.updated_at | string | Row timestamps. |
data.type | object, nullable | The work item type: id, business_id, kind, name, emoji, description, resolved_template_name, resolved_template_languages, is_archived, archived_at, version, sla_target_ms, sla_calendar_id, created_at, updated_at. |
data.status | object, nullable | The current status: id, business_id, parent_stage, label, internal_label, sort_order, assignment_mode, owning_team_id, is_archived, archived_at, version, created_at, updated_at. |
data.contact | object, nullable | The linked conversation's profile, same shape as data.contact on conversation events. Null when the item has no conversation. |
data.comments | array | Every non-deleted comment in time order: id, business_id, work_item_id, author_user_id, author (user object, same shape as on conversation events, or null), body, attachments (free-form JSON as stored; keys are not part of the stable contract), mentions, created_at, edited_at, deleted_at. |
data.actor | object | type is user or system; user is the user object when type is user. |
onWorkItemCsatAdded
A contact rated a resolved work item.
work_item.csat_added
| Field | Type | Description |
|---|---|---|
data.id | string | Work item id. |
data.reference | string | The reference shown in the UI, e.g. TKT-1042. |
data.status_id | string | null | Custom status within the stage. null when the configuration defines none. |
data.channel_id | string | The customer channel the survey was sent to — the work item's own channel, one of a contact's channels, not the contact itself. |
data.conversation_id | string | Conversation the item was created from, when it came from one. |
data.resolution_sequence | integer | Which resolution the rating is for — 1 is the first, 2 after one reopen. |
data.resolved_at | string · date-time | null | When it was last resolved. null while it is open. |
data.csat_rating | string | The rating bucket the contact chose. |
data.csat_experience | string | The experience reason picked alongside the rating. |
data.csat_feedback | string | Free-text feedback from the contact. |
Campaign#
campaign
A broadcast moving through scheduling, sending and completion.
5 events
onCampaignScheduled
A campaign was scheduled to run.
campaign.scheduled
| Field | Type | Description |
|---|---|---|
data.id | string | Campaign id. |
data.title | string | Campaign name as entered in the UI. |
data.status | string | Status after the change — scheduled, in_progress, done, failed, cancelled. |
delta | object | Present only where a transition is meaningful. Absent otherwise. |
delta.from | object | Values before the change. null when there is no prior state to report. |
delta.from.status | string | Status it moved from. |
delta.to | object | Values after the change. |
delta.to.status | string | Status it moved to. |
onCampaignSent
A campaign started sending.
campaign.sent
| Field | Type | Description |
|---|---|---|
data.id | string | Campaign id. |
data.title | string | Campaign name as entered in the UI. |
data.status | string | Status after the change — scheduled, in_progress, done, failed, cancelled. |
delta | object | Present only where a transition is meaningful. Absent otherwise. |
delta.from | object | Values before the change. null when there is no prior state to report. |
delta.from.status | string | Status it moved from. |
delta.to | object | Values after the change. |
delta.to.status | string | Status it moved to. |
onCampaignCompleted
A campaign finished sending.
campaign.completed
| Field | Type | Description |
|---|---|---|
data.id | string | Campaign id. |
data.title | string | Campaign name as entered in the UI. |
data.status | string | Status after the change — scheduled, in_progress, done, failed, cancelled. |
delta | object | Present only where a transition is meaningful. Absent otherwise. |
delta.from | object | Values before the change. null when there is no prior state to report. |
delta.from.status | string | Status it moved from. |
delta.to | object | Values after the change. |
delta.to.status | string | Status it moved to. |
onCampaignFailed
A campaign stopped because of a failure.
campaign.failed
| Field | Type | Description |
|---|---|---|
data.id | string | Campaign id. |
data.title | string | Campaign name as entered in the UI. |
data.status | string | Status after the change — scheduled, in_progress, done, failed, cancelled. |
delta | object | Present only where a transition is meaningful. Absent otherwise. |
delta.from | object | Values before the change. null when there is no prior state to report. |
delta.from.status | string | Status it moved from. |
delta.to | object | Values after the change. |
delta.to.status | string | Status it moved to. |
onCampaignCancelled
A campaign was cancelled before it finished.
campaign.cancelled
| Field | Type | Description |
|---|---|---|
data.id | string | Campaign id. |
data.title | string | Campaign name as entered in the UI. |
data.status | string | Status after the change — scheduled, in_progress, done, failed, cancelled. |
delta | object | Present only where a transition is meaningful. Absent otherwise. |
delta.from | object | Values before the change. null when there is no prior state to report. |
delta.from.status | string | Status it moved from. |
delta.to | object | Values after the change. |
delta.to.status | string | Status it moved to. |
Verifying the signature#
Anyone who learns your endpoint URL can post to it. The signature distinguishes a real delivery from a forged one, so verify it before acting on a payload.
The signed payload is the timestamp, a literal full stop, and the raw request body:
signed_payload = X-Bc-Timestamp + "." + raw_body
expected = hex( HMAC_SHA256( key = whsec_…, message = signed_payload ) )Compare expected with X-Bc-Signature.
// Node.js — note req.rawBody, not the parsed object
const crypto = require("crypto");
function verify(rawBody, headers, secret) {
const ts = headers["x-bc-timestamp"];
const sig = headers["x-bc-signature"];
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.${rawBody}`)
.digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
return ok && fresh;
}# Python
import hmac, hashlib, time
def verify(raw_body: bytes, headers, secret: str) -> bool:
ts = headers["X-Bc-Timestamp"]
sig = headers["X-Bc-Signature"]
expected = hmac.new(
secret.encode(),
f"{ts}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
fresh = abs(time.time() - int(ts)) < 300
return hmac.compare_digest(expected, sig) and freshSign over the bytes you received. Parsing the JSON and re-serialising it will reorder keys and change whitespace, and the signature will not match. Capture the raw body before any middleware touches it.
Replay protection#
The signature covers the timestamp, so an attacker cannot alter it — but they could replay a captured request verbatim. Rejecting anything older than a few minutes closes that window.
BusinessChat does not impose a tolerance, so the window is yours to choose. Five minutes, as in the examples above, is a reasonable default.
Also compare in constant time — timingSafeEqual / compare_digest rather than
==, which leaks timing information that can be used to recover a valid signature.
Delivery & retries#
A delivery succeeds when your endpoint answers with any 2xx status. Anything else — including no response at all — is a failure.
5xx, plus 408 and
429, plus no response at all. Other 4xx responses are treated as a permanent
rejection and are not retried.Duplicates are expected#
Delivery is at-least-once, not exactly-once. A retry fires whenever success is not confirmed — including when your endpoint processed the event correctly but the response was lost. Your handler must tolerate seeing the same event twice.
Deduplicate on idempotency_key. It is unique per subscription per occurrence and constant across
retries, which X-Bc-Delivery-Id deliberately is not — that changes on every attempt, which is what
makes it useful in logs.
Re-sending a delivery#
There is a second way you may see the same event twice, and it is worth planning for. Someone on the BusinessChat side — you, or our support team — can manually re-send any delivery that did not succeed and has no further automatic attempt coming. That is a wider set than "it used up its retries", and it deliberately includes deliveries we never retried at all:
- one that exhausted its retries — all three attempts made, none successful;
- one rejected permanently on the first attempt — a
4xxoutside the retryable set, which we never retry. Nothing further was ever going to be sent, so it is re-sendable straight away; - one recorded as Blocked — see Blocked deliveries;
- one that ended Unknown — see Delivery statuses.
This usually happens once your endpoint has been fixed, which may be hours or days after the original event. A delivery that still has a retry scheduled is not offered — pressing then would only fire a send we were about to make anyway.
A manual re-send is an ordinary delivery in every respect that matters to your handler: same signature scheme,
same headers, and the same idempotency_key as the original. So the deduplication
above already covers it — if you have processed that key, drop it. Two things about it are worth knowing:
- It can arrive long after
occurred_at. The payload is the one we captured at the time, so treat it as a record of what happened then, not as current state. If your handler needs the latest state, re-read it from the API rather than trusting an old payload. - It can arrive out of order relative to events you have already received. Re-sends are made oldest-first, but they are still catching up on a backlog while live events continue to flow.
Some re-sent deliveries — the Unknown ones — were interrupted before we could record
whether your endpoint answered, so we genuinely do
not know whether you received them. Re-sending those is a deliberate choice made by whoever pressed the button,
and the idempotency_key is precisely what makes it safe: a handler that deduplicates correctly sees
no difference between a delivery it never got and one it has already processed.
Which button to use#
There are two, and they are not the same action at different scales. One sends a delivery now and tells you what happened; the other queues a set and tells you how many it took.
Retry this delivery — one delivery, and you see the answer
Expand a delivery in the events log and, if it can be re-sent, its latest attempt carries
Retry this delivery. The send happens while you wait, so the page comes back with the status code
your endpoint returned — a new attempt appears on the same delivery, carrying the same
idempotency_key as the original.
Reach for it when you are dealing with one delivery, or when you want to confirm a fixed endpoint really answers before queueing a backlog at it. Deliveries that cannot be re-sent simply do not show the button — see Re-sending a delivery for which those are.
Retry all — one status, scoped to your filters
Select the Failed, Unknown or Blocked filter and a Retry all button appears above the table, carrying that chip’s count. It queues rather than sending inline, so it returns as soon as the batch is accepted rather than waiting on your endpoint.
- One status at a time. There is no “retry everything”: re-sending a confirmed failure and re-sending a delivery that may already have been processed are different decisions, so they stay different buttons. Under the Unknown filter the button asks for confirmation first, because those deliveries may already have reached you.
- It is scoped to the filters you have set — the date range, the event filter and the search box all narrow it. What it re-sends is what the list in front of you is showing, and the number on the button is that same filtered count.
- It may queue slightly fewer than the count. A delivery whose automatic retry is still scheduled is skipped: it is about to be sent anyway. That is the only reason the two numbers differ.
- Oldest first, in batches. One press takes as much as your per-minute delivery budget allows and reports how many remain, so a large backlog drains over several presses.
The narrowing is what makes this safe to press. Filter to the event, the day, or the single delivery you mean, check that the count on the button matches what the list shows, and only then press it.
Blocked deliveries#
Immediately before each send, BusinessChat re-checks that your endpoint URL is still usable — that its hostname
still resolves, that it resolves to a genuinely public address, and that it is still an https:// URL
with no embedded credentials. If that check fails, the attempt is recorded as
Blocked — we did not send it, and your endpoint never received
it.
The usual cause is DNS. A record that has expired or been removed leaves the hostname unresolvable — a temporary tunnel that lapsed, a subdomain that was deleted, a domain that was not renewed — and every subsequent delivery to that subscription is blocked until it resolves again.
A blocked delivery is not retried. Nothing was sent, so waiting five minutes and sending the same request to the same unusable URL cannot produce a different answer; it is terminal on the first attempt. That is deliberate rather than a limitation: the delivery is finished, and therefore re-sendable, the moment you fix the endpoint — you are not waiting out a retry cycle first.
Blocked is also the one status you can re-send with no risk of a duplicate. A blocked delivery provably never reached your endpoint, so replaying it cannot deliver anything twice — unlike an Unknown attempt, where we genuinely cannot say. The recovery is therefore mechanical: restore the DNS record (or whatever made the URL unusable), confirm it with Test, then open the events log, filter to Blocked and press Retry all blocked. Deliveries are re-sent oldest first, and a large backlog drains over several presses.
What a blocked delivery tells you#
Expand a blocked attempt in the events log and its error message names the specific check that refused it.
There are six, and they point at different fixes. {host} and {scheme} below are filled
in with the values from your own endpoint URL.
| Message | What it means, and what to do |
|---|---|
The endpoint host '{host}' could not be resolved — its DNS record is missing or
expired. | By far the most common. The hostname no longer resolves at all: an expired tunnel, a deleted subdomain, a domain that was not renewed. Restore the DNS record, or edit the subscription to a hostname that resolves. |
The endpoint host '{host}' resolves to an address this platform will not send to. | The hostname resolves, but to an address we do not send to — a private, loopback or otherwise internal-range address. Point it at a publicly routable address. Your endpoint must be reachable from the internet. |
The endpoint URL is not a valid URL. | The stored URL cannot be parsed at all. Re-enter it on the subscription; a stray space or a truncated paste is the usual cause. |
The endpoint URL must use https; got '{scheme}'.or The endpoint URL must use
https; it has no scheme. | Only https:// endpoints are sent to — see
Endpoint URL rules. Fix the scheme on the subscription. |
The endpoint URL names no host. | The URL parsed but has no hostname — a path with no domain in front of it. Re-enter the full URL. |
The endpoint URL must not embed a username or password. | The URL carries
user:pass@ credentials. Remove them and use the subscription's Basic-auth fields instead, which
are stored encrypted rather than sitting in a URL that appears in logs. |
We deliberately never print the address a hostname resolved to, only the hostname itself. The hostname is your own configuration and is what tells you which endpoint to fix; the resolved address would turn this message into a way of asking our network what any name resolves to inside it.
If you press Retry on a single blocked delivery while the endpoint is still unusable, the retry is refused straight away and says so: "This delivery's endpoint URL could not be used, most often because its DNS record is missing or expired. Fix the endpoint's DNS, then retry." Nothing is sent and no new attempt is recorded. Retry all blocked behaves differently — it queues the backlog first and each delivery meets the check individually, so against an endpoint that is still broken it reports a normal queued count and then records a fresh Blocked attempt for each one. Either way nothing reaches your endpoint. Fix the URL, confirm with Test, and only then retry.
Auto-disable#
If a subscription fails continuously for 24 hours with no successful delivery in between, it is switched to Auto-disabled and stops receiving events. Business admins are notified.
The clock is a continuous-failure timer, not a counter: a single 2xx clears it completely. To recover, fix the endpoint, use Test to confirm it answers, then re-enable the subscription.
An attempt that resolves to Unknown — see Delivery statuses — never touches this clock either way. It is a platform-side interruption, not evidence about your endpoint, so it neither starts the timer nor clears it.
An attempt that resolves to Blocked — see Blocked deliveries — does count. An endpoint URL we cannot use is exactly the endpoint this switch exists to stop sending to, so a subscription pointing at a hostname that no longer resolves runs the timer down like any other failure and is auto-disabled after 24 hours, with the same notification to business admins. This is new: a subscription failing this way used to be able to go on failing indefinitely without ever being switched off, and without appearing in the events log at all.
Events that occurred while a subscription was disabled are not backfilled when it is re-enabled.
Monitoring deliveries#
The events log — the second icon on each row — opens a dedicated deliveries page for that subscription, listing every settled attempt newest first. Six chips filter the list — All, Succeeded, Failed, Unknown, Blocked and Retried — each showing its own count, alongside a date range, an event filter and a search box. Results are paginated — 25 rows per page by default, adjustable with the Rows per page control, with Previous and Next to move through the rest. There is no auto-refresh; a Refresh button re-runs the current filters on demand. An attempt that is still in flight joins the list as soon as it has an outcome, normally within seconds — see In-flight deliveries.
Each row that can be re-sent carries its own Retry — see Re-sending a delivery for which those are. Alongside it, Retry all re-sends in bulk, and it is worth being precise about what it acts on:
- It takes one status at a time — Failed, Unknown or Blocked. There is no "retry everything": re-sending a confirmed failure and re-sending a delivery that may already have been processed are separate decisions, so they stay separate buttons.
- It honours the filters you have set. The date range, the event filter and the search box all narrow what it re-sends, so it acts on the deliveries the list is showing you rather than on the whole subscription. The count on the button is that same filtered number.
- It may still queue slightly fewer than the count. A delivery whose automatic retry is already scheduled is left alone — it is about to be sent anyway — so it is counted in the list but skipped here. That is the only reason the two numbers differ.
- It works oldest-first, in batches. One press queues as much as the business's per-minute delivery budget allows and reports how many remain; a large backlog drains over several presses.
Delivery statuses#
Every attempt is exactly one of five statuses. They are mutually exclusive and exhaustive, and
?status= on the API accepts all five — pending, succeeded,
failed, blocked and unknown. Four of them are outcomes, and those
are what the events log in the dashboard shows. pending is not an outcome — it means the attempt has
not finished yet — and it is reported by the API only.
| Status | Meaning |
|---|---|
| Pending | The request is in flight — sent moments ago, no
response recorded yet. Nothing has happened to it that you could act on, so the dashboard's events log leaves
it out and shows it once it settles. The API still reports it, and ?status=pending returns
exactly these — see In-flight deliveries. |
| Succeeded | The endpoint answered with a 2xx. |
| Failed | A confirmed failure: the endpoint answered with anything else, or the send itself errored (timeout, connection refused) and that outcome was recorded. A DNS resolution failure is not here — see Blocked below. |
| Blocked | We did not send this attempt. At the moment of sending, your endpoint URL could not be used, so your endpoint never received it — most often because its DNS record has expired or been removed and the hostname no longer resolves. Distinct from Failed, where your endpoint was reached and answered with an error, and from Unknown, where we could not determine whether it arrived. It is never retried automatically, it does count toward auto-disable, and it can be re-sent without any risk of a duplicate — see Blocked deliveries. |
| Unknown | The attempt was interrupted before its outcome was
recorded — the endpoint may or may not have received it. This is a platform-side interruption, not an
endpoint failure: it does not count toward auto-disable, and it is never retried by us. The same event may
already have been re-delivered independently, so deduplicate on idempotency_key as
usual — see Duplicates are expected. |
failed means confirmed failed. It does not include attempts whose
outcome was never recorded — those are unknown — nor attempts we never sent, which are
blocked. ?status=failed excludes both.
In-flight deliveries#
The events log lists outcomes: succeeded, failed, blocked and unknown. An attempt that is still
being sent — status pending — is left out of the list and has no filter of its own, because there is
nothing yet to read and nothing to do about it. It appears with the rest as soon as your endpoint answers or the
attempt times out, which is at most 15 seconds later (5 to connect, 10 to respond). Refresh, or reopen the log.
The API is different, on purpose. GET /admin/webhook-deliveries returns in-flight
deliveries alongside settled ones and honours ?status=pending, so an integrator building a dashboard,
or our support team looking at a subscription mid-burst, can still see what is happening right now. If you are
reconciling API results against the events log, that is the difference between them: the API reports the whole
table, the dashboard reports the part that has finished.
Expanding a delivery shows its Request ID, idempotency key and request payload once, above the timeline; each attempt below is collapsed to a single line by default, and expanding one reveals that attempt's own response body and error.
429), then hit an upstream failure (500) — expanded here to
show its error and response body — then succeeded (200). Because the last attempt
succeeded, the delivery reads Succeeded overall and also counts toward
the Retried chip.The Request ID identifies the delivery, not a single attempt — it stays the same across every
attempt in a retry chain, as in the example above, and is the right thing to quote when asking support about it.
Each attempt also carries its own X-Bc-Delivery-Id header, which does change between retries — see
Headers.
History retention#
The events log covers the current and previous calendar month; delivery attempts older than that are removed and cannot be recovered. Because the cutoff is a calendar boundary rather than a rolling window, how far back it actually reaches depends on the date: a little over 30 days on the 1st of the month, close to 60 by the last day of the month. It is not a flat “30 days,” even though the longest date-range preset reads 30d — that preset stops there because 30 days is the longest window we can promise is always inside the retained history. A longer fixed preset would sometimes be honest and sometimes silently miss the oldest attempts, depending on the day it was clicked.
Deleting a subscription does not delete its delivery history. Past attempts stay visible in the events log, subject to the same retention above, under a “This webhook no longer exists” heading in place of the subscription's name. This is deliberate: the record of what was sent and received is kept for as long as retention allows, even after the subscription itself is gone.
Testing an endpoint#
The Test action sends a webhook.test event immediately, signed with the real
secret, in the identical envelope shape a real event uses. If your handler can parse and verify the test, it can
parse and verify everything else.
Test sends appear in the events log alongside real ones, named webhook.test. They are sent once
and never retried, so a failed test does not start a retry cycle or count towards auto-disable.
Managing a webhook#
Enabling and disabling#
The Enabled toggle stops and starts delivery without losing the subscription, its secret, or its history. Events that occur while it is off are not queued and are not backfilled.
Editing#
Deleting#
Troubleshooting#
| Symptom | Where to look |
|---|---|
| Nothing arrives | Check the subscription is Active and Enabled, and that
the event you expect is actually in its selection — work_item.created does not deliver
work_item.resolved. |
| Test fails but the URL is right | The error text is reported verbatim. A resolution failure means DNS; a connection failure usually means the host is not publicly reachable. |
| Signature never matches | Almost always the raw body. Verify against the exact
bytes received, not a re-serialised object, and confirm you are signing
timestamp + "." + body. See Verifying the signature. |
| Endpoint returns 3xx | Redirects are not followed. Register the final URL. |
| Duplicate events | Expected under at-least-once delivery. Deduplicate on
idempotency_key. See Duplicates are expected. |
| Subscription auto-disabled | The endpoint failed for 24 hours without a single success. Fix it, test it, then re-enable. See Auto-disable. |
| Every delivery suddenly says "Blocked" | Your endpoint URL stopped being
usable, so nothing was sent and nothing reached you. Check first that its hostname still resolves publicly —
an expired tunnel or a deleted DNS record is the usual cause — and that the URL is still https://
and points at a public address. Fix it, press Test to confirm, then filter the events log to
Blocked and press Retry all blocked; nothing was delivered, so re-sending
cannot duplicate anything. Left unfixed, the subscription is auto-disabled after 24 hours. Expand any
blocked attempt to see which check refused it — see What a blocked
delivery tells you and Blocked deliveries. |
| Delivery status shows "Unknown" | The platform lost track of that attempt's
outcome before it could be recorded — a rare interruption on our side, not a signal about your endpoint. It
does not count toward auto-disable. If the event mattered, expect a possible duplicate from redelivery and
dedupe on idempotency_key. See Delivery statuses. |
Deep-link anchors#
Every id below is part of this document's public contract. They are hand-written, not derived from heading text, so rewording a heading will not break an existing link. Treat them as permanent: link to them from the product UI, from support macros, from anywhere.
Arriving with a hash scrolls to the target and flashes it briefly, so the reader can see which part of
the page the link meant. Linking from the product is just
<a href="/docs/webhooks.html#signature">.
| Anchor | Lands on | Good link from |
|---|---|---|
#overview | Overview | Empty state of the Webhooks list |
#creating | Creating a webhook | The Create webhook dialog |
#creating-url | Endpoint URL rules | A URL validation error |
#creating-events | Choosing events | The events picker |
#secret | The signing secret | The one-time secret dialog |
#events-catalog | Full event catalogue | "Which events exist?" |
#payload-envelope | The envelope | An expanded delivery's payload |
#payload-headers | Headers | Header reference |
#payload-catalog | Payload by event | “What does this event look like?” |
#payload-<eventName> | One event’s example body | A delivery row, the events picker |
#signature | Verifying the signature | Next to the secret, or a 401 from the endpoint |
#signature-replay | Replay protection | Security review notes |
#delivery | Delivery & retries | A failed delivery row |
#delivery-idempotency | Duplicates | Support answer on double-processing |
#delivery-resend | Re-sending a delivery | Support answer on “how do I re-send this?” |
#delivery-resend-buttons | Which button to use | The Retry and Retry all buttons in the events log |
#delivery-blocked | Blocked deliveries | A “Blocked” delivery status, and the Blocked filter |
#delivery-blocked-messages | What a blocked delivery tells you | The error message on an expanded Blocked attempt; a refused Retry |
#delivery-auto-disable | Auto-disable | The auto-disabled notification and status pill |
#monitoring | Monitoring deliveries | The events log page |
#monitoring-statuses | Delivery statuses | “What does status X mean?” |
#monitoring-in-flight | In-flight deliveries | “Why is there no Pending filter?”; API vs. dashboard reconciliation |
#monitoring-retention | History retention | “How far back does this go?”; a deleted subscription's deliveries page |
#testing | Testing an endpoint | The test-fire result dialog |
#managing-delete | Deleting | The delete confirmation |
#troubleshooting | Troubleshooting | Generic help link |
#ts-signature | Troubleshooting: signature row | A signature-mismatch error |
#ts-auto-disabled | Troubleshooting: auto-disabled row | The auto-disabled banner |
#ts-blocked | Troubleshooting: blocked status row | A run of “Blocked” deliveries |
#ts-unknown | Troubleshooting: unknown status row | An “Unknown” delivery status |
Rules for adding anchors#
- Ids are kebab-case and describe the topic, not the wording of the heading.
- Prefix sub-section ids with their parent (
delivery-auto-disable) so the hierarchy is readable from the link alone. - Never remove or repurpose an id. If a section is dropped, leave the id on whatever replaced it, or on its nearest surviving parent.
- Add the id to the table above in the same change, so this list stays the single place to look.