APIMCP ServerWebhooksEvent TrackerCustom Integration
/
Operator Guide

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.

The webhooks list showing two subscriptions The webhooks list showing two subscriptions
Each row shows where events go, which ones are selected, and whether the subscription is currently delivering. The four actions on the right are test-fire, events log, edit and delete.

Every subscription carries five things:

Endpoint
The HTTPS URL every selected event is posted to, as POST with a JSON body.
Secret
A signing key, shown once at creation, used to prove a request genuinely came from BusinessChat.
Selection
Which events this endpoint wants — chosen per object, either all of that object's actions or a named few.
Status
Whether it is delivering: Active, Disabled by you, or Auto-disabled after a long run of failures.
Basic auth (optional)
A username and password, used only if your endpoint sits behind HTTP basic auth. They are sent as an 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.

The empty create webhook form The empty create webhook form

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:

⚠

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.

Specific work item actions selected Specific work item actions selected
The selection is stored as 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.

The one-time signing secret dialog The one-time signing secret dialog
Copy it into your secret store before dismissing this dialog.
🔑

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.

ObjectActions
conversationopened, closed, assigned, status_changed, rated, topic_added, topic_deleted
work_itemcreated, status_changed, resolved, reopened, custom_field_changed, assigned, commented, csat_added, sla_breached, sla_warning, due_date_breached
campaignscheduled, 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#

FieldTypeDescription
event_idstringIdentifies the occurrence. Stable across retries, and the same for every subscriber of that occurrence.
object_idThe id of the thing the event is about — the session for a conversation event, the work item, the campaign.
event_namestringThe camel-case event name.
objectstringThe object the event is about — conversation, work_item, campaign.
actionstringWhat happened to it. object and action together are what you selected.
event_versionintegerSchema version of data. Currently 1 for every event.
occurred_atstring · date-timeWhen the event happened — not when it was delivered. A retry an hour later carries the original timestamp.
idempotency_keystringUnique per subscription per occurrence, prefixed bc_whd_. This is the key to deduplicate on.
dataobjectThe event payload. Its shape depends on the object — see Payload by event.
business_idstringThe business the event belongs to. Constant for one subscription, and the value to route on when one endpoint serves several businesses.
deltaobjectPresent only where a transition is meaningful — conversation.status_changed carries the status it moved to. Absent otherwise.

Headers#

HeaderContents
X-Bc-SignatureHMAC-SHA256 of the signed payload, hex encoded.
X-Bc-TimestampUnix epoch seconds at which the request was signed.
X-Bc-EventThe event name, matching event_name in the body.
X-Bc-Event-VersionThe schema version, matching event_version.
X-Bc-Delivery-IdIdentifies this individual attempt. Differs between retries of the same event.
X-Bc-Idempotency-KeyMatches idempotency_key in the body.
AuthorizationOnly 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
FieldTypeDescription
data.session_idstringThe session this event is about.
data.conversation_idstringThe conversation the session belongs to.
data.started_atstring · date-timeWhen the session opened.
data.ended_atstring · date-time · nullWhen the session closed. null while it is open.
data.ended_bystring · nullWho closed the session — user, contact, bot, system.
data.initiated_bystringWho opened the session.
data.delegation_modestringHow the session is delegated.
data.escalation_countintegerHow many times the session was escalated.
data.conversation_session_statusstringopen, closed or escalated.
data.contactobjectThe contact profile, resolved in full.
data.actorobjectWho caused the event, with the user or team resolved.
data.assignorobject · nullWho made the assignment, with user and team resolved.
data.assigneeobject · nullWho the session is assigned to. Falls back to the conversation when the assignment was made in an earlier session.
data.topicsarrayEvery topic on the session, with who added it and when.
data.messagesarrayThe session transcript, oldest first.
data.eventsarrayThe session's events, oldest first.
onConversationClosed A conversation was closed. conversation.closed
FieldTypeDescription
data.session_idstringThe session this event is about.
data.conversation_idstringThe conversation the session belongs to.
data.started_atstring · date-timeWhen the session opened.
data.ended_atstring · date-time · nullWhen the session closed. null while it is open.
data.ended_bystring · nullWho closed the session — user, contact, bot, system.
data.initiated_bystringWho opened the session.
data.delegation_modestringHow the session is delegated.
data.escalation_countintegerHow many times the session was escalated.
data.conversation_session_statusstringopen, closed or escalated.
data.contactobjectThe contact profile, resolved in full.
data.actorobjectWho caused the event, with the user or team resolved.
data.assignorobject · nullWho made the assignment, with user and team resolved.
data.assigneeobject · nullWho the session is assigned to. Falls back to the conversation when the assignment was made in an earlier session.
data.topicsarrayEvery topic on the session, with who added it and when.
data.messagesarrayThe session transcript, oldest first.
data.eventsarrayThe session's events, oldest first.
onConversationAssigned A conversation was assigned. conversation.assigned
FieldTypeDescription
data.session_idstringThe session this event is about.
data.conversation_idstringThe conversation the session belongs to.
data.started_atstring · date-timeWhen the session opened.
data.ended_atstring · date-time · nullWhen the session closed. null while it is open.
data.ended_bystring · nullWho closed the session — user, contact, bot, system.
data.initiated_bystringWho opened the session.
data.delegation_modestringHow the session is delegated.
data.escalation_countintegerHow many times the session was escalated.
data.conversation_session_statusstringopen, closed or escalated.
data.contactobjectThe contact profile, resolved in full.
data.actorobjectWho caused the event, with the user or team resolved.
data.assignorobject · nullWho made the assignment, with user and team resolved.
data.assigneeobject · nullWho the session is assigned to. Falls back to the conversation when the assignment was made in an earlier session.
data.topicsarrayEvery topic on the session, with who added it and when.
data.messagesarrayThe session transcript, oldest first.
data.eventsarrayThe session's events, oldest first.
onConversationStatusChanged A conversation was escalated, changing its status. conversation.status_changed
FieldTypeDescription
data.session_idstringThe session this event is about.
data.conversation_idstringThe conversation the session belongs to.
data.started_atstring · date-timeWhen the session opened.
data.ended_atstring · date-time · nullWhen the session closed. null while it is open.
data.ended_bystring · nullWho closed the session — user, contact, bot, system.
data.initiated_bystringWho opened the session.
data.delegation_modestringHow the session is delegated.
data.escalation_countintegerHow many times the session was escalated.
data.conversation_session_statusstringopen, closed or escalated.
data.contactobjectThe contact profile, resolved in full.
data.actorobjectWho caused the event, with the user or team resolved.
data.assignorobject · nullWho made the assignment, with user and team resolved.
data.assigneeobject · nullWho the session is assigned to. Falls back to the conversation when the assignment was made in an earlier session.
data.topicsarrayEvery topic on the session, with who added it and when.
data.messagesarrayThe session transcript, oldest first.
data.eventsarrayThe session's events, oldest first.
onConversationRated A contact rated a conversation. conversation.rated
FieldTypeDescription
data.session_idstringThe session this event is about.
data.conversation_idstringThe conversation the session belongs to.
data.started_atstring · date-timeWhen the session opened.
data.ended_atstring · date-time · nullWhen the session closed. null while it is open.
data.ended_bystring · nullWho closed the session — user, contact, bot, system.
data.initiated_bystringWho opened the session.
data.delegation_modestringHow the session is delegated.
data.escalation_countintegerHow many times the session was escalated.
data.conversation_session_statusstringopen, closed or escalated.
data.contactobjectThe contact profile, resolved in full.
data.actorobjectWho caused the event, with the user or team resolved.
data.assignorobject · nullWho made the assignment, with user and team resolved.
data.assigneeobject · nullWho the session is assigned to. Falls back to the conversation when the assignment was made in an earlier session.
data.topicsarrayEvery topic on the session, with who added it and when.
data.messagesarrayThe session transcript, oldest first.
data.eventsarrayThe session's events, oldest first.
onConversationTopicAdded A topic was added to a conversation. conversation.topic_added
FieldTypeDescription
data.session_idstringThe session this event is about.
data.conversation_idstringThe conversation the session belongs to.
data.started_atstring · date-timeWhen the session opened.
data.ended_atstring · date-time · nullWhen the session closed. null while it is open.
data.ended_bystring · nullWho closed the session — user, contact, bot, system.
data.initiated_bystringWho opened the session.
data.delegation_modestringHow the session is delegated.
data.escalation_countintegerHow many times the session was escalated.
data.conversation_session_statusstringopen, closed or escalated.
data.contactobjectThe contact profile, resolved in full.
data.actorobjectWho caused the event, with the user or team resolved.
data.assignorobject · nullWho made the assignment, with user and team resolved.
data.assigneeobject · nullWho the session is assigned to. Falls back to the conversation when the assignment was made in an earlier session.
data.topicsarrayEvery topic on the session, with who added it and when.
data.messagesarrayThe session transcript, oldest first.
data.eventsarrayThe session's events, oldest first.
onConversationTopicDeleted A topic was removed from a conversation. conversation.topic_deleted
FieldTypeDescription
data.session_idstringThe session this event is about.
data.conversation_idstringThe conversation the session belongs to.
data.started_atstring · date-timeWhen the session opened.
data.ended_atstring · date-time · nullWhen the session closed. null while it is open.
data.ended_bystring · nullWho closed the session — user, contact, bot, system.
data.initiated_bystringWho opened the session.
data.delegation_modestringHow the session is delegated.
data.escalation_countintegerHow many times the session was escalated.
data.conversation_session_statusstringopen, closed or escalated.
data.contactobjectThe contact profile, resolved in full.
data.actorobjectWho caused the event, with the user or team resolved.
data.assignorobject · nullWho made the assignment, with user and team resolved.
data.assigneeobject · nullWho the session is assigned to. Falls back to the conversation when the assignment was made in an earlier session.
data.topicsarrayEvery topic on the session, with who added it and when.
data.messagesarrayThe session transcript, oldest first.
data.eventsarrayThe 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
FieldTypeDescription
data.idstringWork item id.
data.business_idstringBusiness id.
data.public_referencestringThe reference shown in the UI, e.g. TKT-1042.
data.kindstringticket, task or incident.
data.config_idstringId of the type the item was created from; see data.type.
data.current_status_idstring, nullableId of the current status; see data.status.
data.current_stagestringsubmitted, in_progress, waiting_on_customer, resolved, cancelled.
data.titlestringTitle.
data.descriptionstringDescription, may be empty.
data.contact_idstring, nullablePrimary contact id.
data.conversation_idstring, nullableLinked conversation id.
data.conversation_session_idstring, nullableLinked session id.
data.creator_user_id, data.creator_team_idstring, nullableWho created it.
data.assignee_user_id, data.assignee_team_idstring, nullableCurrent assignee.
data.assigned_by_user_id, data.assigned_atstring, nullableLast assignment.
data.creation_channelstringconversation, manual, api.
data.severitystring, nullablelow, medium, high, critical.
data.due_atstring, nullableDue date, ISO-8601.
data.reopen_countintegerTimes reopened.
data.last_activity_at, data.activity_countstring / integerActivity.
data.versionintegerOptimistic-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_levelmixed, nullableThe 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_atstring, nullableWhen each stage was first entered.
data.created_at, data.updated_atstringRow timestamps.
data.typeobject, nullableThe 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.statusobject, nullableThe 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.contactobject, nullableThe linked conversation's profile, same shape as data.contact on conversation events. Null when the item has no conversation.
data.commentsarrayEvery 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.actorobjecttype 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
FieldTypeDescription
data.idstringWork item id.
data.business_idstringBusiness id.
data.public_referencestringThe reference shown in the UI, e.g. TKT-1042.
data.kindstringticket, task or incident.
data.config_idstringId of the type the item was created from; see data.type.
data.current_status_idstring, nullableId of the current status; see data.status.
data.current_stagestringsubmitted, in_progress, waiting_on_customer, resolved, cancelled.
data.titlestringTitle.
data.descriptionstringDescription, may be empty.
data.contact_idstring, nullablePrimary contact id.
data.conversation_idstring, nullableLinked conversation id.
data.conversation_session_idstring, nullableLinked session id.
data.creator_user_id, data.creator_team_idstring, nullableWho created it.
data.assignee_user_id, data.assignee_team_idstring, nullableCurrent assignee.
data.assigned_by_user_id, data.assigned_atstring, nullableLast assignment.
data.creation_channelstringconversation, manual, api.
data.severitystring, nullablelow, medium, high, critical.
data.due_atstring, nullableDue date, ISO-8601.
data.reopen_countintegerTimes reopened.
data.last_activity_at, data.activity_countstring / integerActivity.
data.versionintegerOptimistic-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_levelmixed, nullableThe 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_atstring, nullableWhen each stage was first entered.
data.created_at, data.updated_atstringRow timestamps.
data.typeobject, nullableThe 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.statusobject, nullableThe 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.contactobject, nullableThe linked conversation's profile, same shape as data.contact on conversation events. Null when the item has no conversation.
data.commentsarrayEvery 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.actorobjecttype is user or system; user is the user object when type is user.
deltaobjectPresent 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.fromobjectValues before the change. null when there is no prior state to report.
delta.from.stagestringStage it moved from.
delta.toobjectValues after the change.
delta.to.stagestringStage it moved to.
onWorkItemResolved A work item was resolved. work_item.resolved
FieldTypeDescription
data.idstringWork item id.
data.business_idstringBusiness id.
data.public_referencestringThe reference shown in the UI, e.g. TKT-1042.
data.kindstringticket, task or incident.
data.config_idstringId of the type the item was created from; see data.type.
data.current_status_idstring, nullableId of the current status; see data.status.
data.current_stagestringsubmitted, in_progress, waiting_on_customer, resolved, cancelled.
data.titlestringTitle.
data.descriptionstringDescription, may be empty.
data.contact_idstring, nullablePrimary contact id.
data.conversation_idstring, nullableLinked conversation id.
data.conversation_session_idstring, nullableLinked session id.
data.creator_user_id, data.creator_team_idstring, nullableWho created it.
data.assignee_user_id, data.assignee_team_idstring, nullableCurrent assignee.
data.assigned_by_user_id, data.assigned_atstring, nullableLast assignment.
data.creation_channelstringconversation, manual, api.
data.severitystring, nullablelow, medium, high, critical.
data.due_atstring, nullableDue date, ISO-8601.
data.reopen_countintegerTimes reopened.
data.last_activity_at, data.activity_countstring / integerActivity.
data.versionintegerOptimistic-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_levelmixed, nullableThe 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_atstring, nullableWhen each stage was first entered.
data.created_at, data.updated_atstringRow timestamps.
data.typeobject, nullableThe 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.statusobject, nullableThe 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.contactobject, nullableThe linked conversation's profile, same shape as data.contact on conversation events. Null when the item has no conversation.
data.commentsarrayEvery 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.actorobjecttype is user or system; user is the user object when type is user.
deltaobjectPresent 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.fromobjectValues before the change. null when there is no prior state to report.
delta.from.stagestringStage it moved from.
delta.toobjectValues after the change.
delta.to.stagestringStage it moved to.
onWorkItemReopened A resolved work item was reopened. work_item.reopened
FieldTypeDescription
data.idstringWork item id.
data.business_idstringBusiness id.
data.public_referencestringThe reference shown in the UI, e.g. TKT-1042.
data.kindstringticket, task or incident.
data.config_idstringId of the type the item was created from; see data.type.
data.current_status_idstring, nullableId of the current status; see data.status.
data.current_stagestringsubmitted, in_progress, waiting_on_customer, resolved, cancelled.
data.titlestringTitle.
data.descriptionstringDescription, may be empty.
data.contact_idstring, nullablePrimary contact id.
data.conversation_idstring, nullableLinked conversation id.
data.conversation_session_idstring, nullableLinked session id.
data.creator_user_id, data.creator_team_idstring, nullableWho created it.
data.assignee_user_id, data.assignee_team_idstring, nullableCurrent assignee.
data.assigned_by_user_id, data.assigned_atstring, nullableLast assignment.
data.creation_channelstringconversation, manual, api.
data.severitystring, nullablelow, medium, high, critical.
data.due_atstring, nullableDue date, ISO-8601.
data.reopen_countintegerTimes reopened.
data.last_activity_at, data.activity_countstring / integerActivity.
data.versionintegerOptimistic-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_levelmixed, nullableThe 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_atstring, nullableWhen each stage was first entered.
data.created_at, data.updated_atstringRow timestamps.
data.typeobject, nullableThe 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.statusobject, nullableThe 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.contactobject, nullableThe linked conversation's profile, same shape as data.contact on conversation events. Null when the item has no conversation.
data.commentsarrayEvery 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.actorobjecttype is user or system; user is the user object when type is user.
deltaobjectPresent 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.fromobjectValues before the change. null when there is no prior state to report.
delta.from.stagestringStage it moved from.
delta.toobjectValues after the change.
delta.to.stagestringStage it moved to.
onWorkItemAssigned A work item was assigned to someone else. work_item.assigned
FieldTypeDescription
data.idstringWork item id.
data.business_idstringBusiness id.
data.public_referencestringThe reference shown in the UI, e.g. TKT-1042.
data.kindstringticket, task or incident.
data.config_idstringId of the type the item was created from; see data.type.
data.current_status_idstring, nullableId of the current status; see data.status.
data.current_stagestringsubmitted, in_progress, waiting_on_customer, resolved, cancelled.
data.titlestringTitle.
data.descriptionstringDescription, may be empty.
data.contact_idstring, nullablePrimary contact id.
data.conversation_idstring, nullableLinked conversation id.
data.conversation_session_idstring, nullableLinked session id.
data.creator_user_id, data.creator_team_idstring, nullableWho created it.
data.assignee_user_id, data.assignee_team_idstring, nullableCurrent assignee.
data.assigned_by_user_id, data.assigned_atstring, nullableLast assignment.
data.creation_channelstringconversation, manual, api.
data.severitystring, nullablelow, medium, high, critical.
data.due_atstring, nullableDue date, ISO-8601.
data.reopen_countintegerTimes reopened.
data.last_activity_at, data.activity_countstring / integerActivity.
data.versionintegerOptimistic-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_levelmixed, nullableThe 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_atstring, nullableWhen each stage was first entered.
data.created_at, data.updated_atstringRow timestamps.
data.typeobject, nullableThe 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.statusobject, nullableThe 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.contactobject, nullableThe linked conversation's profile, same shape as data.contact on conversation events. Null when the item has no conversation.
data.commentsarrayEvery 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.actorobjecttype is user or system; user is the user object when type is user.
deltaobjectPresent 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.fromobjectValues before the change. null when there is no prior state to report.
delta.from.assignee_user_idstringUser it was assigned to before.
delta.toobjectValues after the change.
delta.to.assignee_user_idstringUser it is assigned to now.
onWorkItemCommented A comment was added to a work item. work_item.commented
FieldTypeDescription
data.idstringWork item id.
data.business_idstringBusiness id.
data.public_referencestringThe reference shown in the UI, e.g. TKT-1042.
data.kindstringticket, task or incident.
data.config_idstringId of the type the item was created from; see data.type.
data.current_status_idstring, nullableId of the current status; see data.status.
data.current_stagestringsubmitted, in_progress, waiting_on_customer, resolved, cancelled.
data.titlestringTitle.
data.descriptionstringDescription, may be empty.
data.contact_idstring, nullablePrimary contact id.
data.conversation_idstring, nullableLinked conversation id.
data.conversation_session_idstring, nullableLinked session id.
data.creator_user_id, data.creator_team_idstring, nullableWho created it.
data.assignee_user_id, data.assignee_team_idstring, nullableCurrent assignee.
data.assigned_by_user_id, data.assigned_atstring, nullableLast assignment.
data.creation_channelstringconversation, manual, api.
data.severitystring, nullablelow, medium, high, critical.
data.due_atstring, nullableDue date, ISO-8601.
data.reopen_countintegerTimes reopened.
data.last_activity_at, data.activity_countstring / integerActivity.
data.versionintegerOptimistic-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_levelmixed, nullableThe 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_atstring, nullableWhen each stage was first entered.
data.created_at, data.updated_atstringRow timestamps.
data.typeobject, nullableThe 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.statusobject, nullableThe 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.contactobject, nullableThe linked conversation's profile, same shape as data.contact on conversation events. Null when the item has no conversation.
data.commentsarrayEvery 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.actorobjecttype 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
FieldTypeDescription
data.idstringWork item id.
data.business_idstringBusiness id.
data.public_referencestringThe reference shown in the UI, e.g. TKT-1042.
data.kindstringticket, task or incident.
data.config_idstringId of the type the item was created from; see data.type.
data.current_status_idstring, nullableId of the current status; see data.status.
data.current_stagestringsubmitted, in_progress, waiting_on_customer, resolved, cancelled.
data.titlestringTitle.
data.descriptionstringDescription, may be empty.
data.contact_idstring, nullablePrimary contact id.
data.conversation_idstring, nullableLinked conversation id.
data.conversation_session_idstring, nullableLinked session id.
data.creator_user_id, data.creator_team_idstring, nullableWho created it.
data.assignee_user_id, data.assignee_team_idstring, nullableCurrent assignee.
data.assigned_by_user_id, data.assigned_atstring, nullableLast assignment.
data.creation_channelstringconversation, manual, api.
data.severitystring, nullablelow, medium, high, critical.
data.due_atstring, nullableDue date, ISO-8601.
data.reopen_countintegerTimes reopened.
data.last_activity_at, data.activity_countstring / integerActivity.
data.versionintegerOptimistic-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_levelmixed, nullableThe 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_atstring, nullableWhen each stage was first entered.
data.created_at, data.updated_atstringRow timestamps.
data.typeobject, nullableThe 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.statusobject, nullableThe 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.contactobject, nullableThe linked conversation's profile, same shape as data.contact on conversation events. Null when the item has no conversation.
data.commentsarrayEvery 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.actorobjecttype is user or system; user is the user object when type is user.
deltaobjectPresent 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.fromobjectValues before the change. null when there is no prior state to report.
delta.from.{custom_field_id}string | nullValue before, keyed by the custom field’s id.
delta.toobjectValues after the change.
delta.to.{custom_field_id}stringValue now, keyed by the same custom field id.
onWorkItemSlaWarning A work item is approaching its SLA deadline. work_item.sla_warning
FieldTypeDescription
data.idstringWork item id.
data.business_idstringBusiness id.
data.public_referencestringThe reference shown in the UI, e.g. TKT-1042.
data.kindstringticket, task or incident.
data.config_idstringId of the type the item was created from; see data.type.
data.current_status_idstring, nullableId of the current status; see data.status.
data.current_stagestringsubmitted, in_progress, waiting_on_customer, resolved, cancelled.
data.titlestringTitle.
data.descriptionstringDescription, may be empty.
data.contact_idstring, nullablePrimary contact id.
data.conversation_idstring, nullableLinked conversation id.
data.conversation_session_idstring, nullableLinked session id.
data.creator_user_id, data.creator_team_idstring, nullableWho created it.
data.assignee_user_id, data.assignee_team_idstring, nullableCurrent assignee.
data.assigned_by_user_id, data.assigned_atstring, nullableLast assignment.
data.creation_channelstringconversation, manual, api.
data.severitystring, nullablelow, medium, high, critical.
data.due_atstring, nullableDue date, ISO-8601.
data.reopen_countintegerTimes reopened.
data.last_activity_at, data.activity_countstring / integerActivity.
data.versionintegerOptimistic-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_levelmixed, nullableThe 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_atstring, nullableWhen each stage was first entered.
data.created_at, data.updated_atstringRow timestamps.
data.typeobject, nullableThe 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.statusobject, nullableThe 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.contactobject, nullableThe linked conversation's profile, same shape as data.contact on conversation events. Null when the item has no conversation.
data.commentsarrayEvery 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.actorobjecttype is user or system; user is the user object when type is user.
deltaobjectPresent 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.fromobjectValues before the change. null when there is no prior state to report.
delta.from.sla_risk_levelstringRisk level before.
delta.toobjectValues after the change.
delta.to.sla_risk_levelstringRisk level now.
onWorkItemSlaBreached A work item passed its SLA deadline. work_item.sla_breached
FieldTypeDescription
data.idstringWork item id.
data.business_idstringBusiness id.
data.public_referencestringThe reference shown in the UI, e.g. TKT-1042.
data.kindstringticket, task or incident.
data.config_idstringId of the type the item was created from; see data.type.
data.current_status_idstring, nullableId of the current status; see data.status.
data.current_stagestringsubmitted, in_progress, waiting_on_customer, resolved, cancelled.
data.titlestringTitle.
data.descriptionstringDescription, may be empty.
data.contact_idstring, nullablePrimary contact id.
data.conversation_idstring, nullableLinked conversation id.
data.conversation_session_idstring, nullableLinked session id.
data.creator_user_id, data.creator_team_idstring, nullableWho created it.
data.assignee_user_id, data.assignee_team_idstring, nullableCurrent assignee.
data.assigned_by_user_id, data.assigned_atstring, nullableLast assignment.
data.creation_channelstringconversation, manual, api.
data.severitystring, nullablelow, medium, high, critical.
data.due_atstring, nullableDue date, ISO-8601.
data.reopen_countintegerTimes reopened.
data.last_activity_at, data.activity_countstring / integerActivity.
data.versionintegerOptimistic-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_levelmixed, nullableThe 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_atstring, nullableWhen each stage was first entered.
data.created_at, data.updated_atstringRow timestamps.
data.typeobject, nullableThe 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.statusobject, nullableThe 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.contactobject, nullableThe linked conversation's profile, same shape as data.contact on conversation events. Null when the item has no conversation.
data.commentsarrayEvery 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.actorobjecttype is user or system; user is the user object when type is user.
deltaobjectPresent 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.fromobjectValues before the change. null when there is no prior state to report.
delta.from.sla_statestringSLA state before.
delta.toobjectValues after the change.
delta.to.sla_statestringSLA state now.
onWorkItemDueDateBreached A work item passed its due date. work_item.due_date_breached
FieldTypeDescription
data.idstringWork item id.
data.business_idstringBusiness id.
data.public_referencestringThe reference shown in the UI, e.g. TKT-1042.
data.kindstringticket, task or incident.
data.config_idstringId of the type the item was created from; see data.type.
data.current_status_idstring, nullableId of the current status; see data.status.
data.current_stagestringsubmitted, in_progress, waiting_on_customer, resolved, cancelled.
data.titlestringTitle.
data.descriptionstringDescription, may be empty.
data.contact_idstring, nullablePrimary contact id.
data.conversation_idstring, nullableLinked conversation id.
data.conversation_session_idstring, nullableLinked session id.
data.creator_user_id, data.creator_team_idstring, nullableWho created it.
data.assignee_user_id, data.assignee_team_idstring, nullableCurrent assignee.
data.assigned_by_user_id, data.assigned_atstring, nullableLast assignment.
data.creation_channelstringconversation, manual, api.
data.severitystring, nullablelow, medium, high, critical.
data.due_atstring, nullableDue date, ISO-8601.
data.reopen_countintegerTimes reopened.
data.last_activity_at, data.activity_countstring / integerActivity.
data.versionintegerOptimistic-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_levelmixed, nullableThe 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_atstring, nullableWhen each stage was first entered.
data.created_at, data.updated_atstringRow timestamps.
data.typeobject, nullableThe 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.statusobject, nullableThe 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.contactobject, nullableThe linked conversation's profile, same shape as data.contact on conversation events. Null when the item has no conversation.
data.commentsarrayEvery 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.actorobjecttype 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
FieldTypeDescription
data.idstringWork item id.
data.referencestringThe reference shown in the UI, e.g. TKT-1042.
data.status_idstring | nullCustom status within the stage. null when the configuration defines none.
data.channel_idstringThe 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_idstringConversation the item was created from, when it came from one.
data.resolution_sequenceintegerWhich resolution the rating is for — 1 is the first, 2 after one reopen.
data.resolved_atstring · date-time | nullWhen it was last resolved. null while it is open.
data.csat_ratingstringThe rating bucket the contact chose.
data.csat_experiencestringThe experience reason picked alongside the rating.
data.csat_feedbackstringFree-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
FieldTypeDescription
data.idstringCampaign id.
data.titlestringCampaign name as entered in the UI.
data.statusstringStatus after the change — scheduled, in_progress, done, failed, cancelled.
deltaobjectPresent only where a transition is meaningful. Absent otherwise.
delta.fromobjectValues before the change. null when there is no prior state to report.
delta.from.statusstringStatus it moved from.
delta.toobjectValues after the change.
delta.to.statusstringStatus it moved to.
onCampaignSent A campaign started sending. campaign.sent
FieldTypeDescription
data.idstringCampaign id.
data.titlestringCampaign name as entered in the UI.
data.statusstringStatus after the change — scheduled, in_progress, done, failed, cancelled.
deltaobjectPresent only where a transition is meaningful. Absent otherwise.
delta.fromobjectValues before the change. null when there is no prior state to report.
delta.from.statusstringStatus it moved from.
delta.toobjectValues after the change.
delta.to.statusstringStatus it moved to.
onCampaignCompleted A campaign finished sending. campaign.completed
FieldTypeDescription
data.idstringCampaign id.
data.titlestringCampaign name as entered in the UI.
data.statusstringStatus after the change — scheduled, in_progress, done, failed, cancelled.
deltaobjectPresent only where a transition is meaningful. Absent otherwise.
delta.fromobjectValues before the change. null when there is no prior state to report.
delta.from.statusstringStatus it moved from.
delta.toobjectValues after the change.
delta.to.statusstringStatus it moved to.
onCampaignFailed A campaign stopped because of a failure. campaign.failed
FieldTypeDescription
data.idstringCampaign id.
data.titlestringCampaign name as entered in the UI.
data.statusstringStatus after the change — scheduled, in_progress, done, failed, cancelled.
deltaobjectPresent only where a transition is meaningful. Absent otherwise.
delta.fromobjectValues before the change. null when there is no prior state to report.
delta.from.statusstringStatus it moved from.
delta.toobjectValues after the change.
delta.to.statusstringStatus it moved to.
onCampaignCancelled A campaign was cancelled before it finished. campaign.cancelled
FieldTypeDescription
data.idstringCampaign id.
data.titlestringCampaign name as entered in the UI.
data.statusstringStatus after the change — scheduled, in_progress, done, failed, cancelled.
deltaobjectPresent only where a transition is meaningful. Absent otherwise.
delta.fromobjectValues before the change. null when there is no prior state to report.
delta.from.statusstringStatus it moved from.
delta.toobjectValues after the change.
delta.to.statusstringStatus 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 fresh
⚠

Sign 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.

Timeouts
5 seconds to connect, 10 seconds to respond. Acknowledge fast and do your work afterwards; a slow endpoint reads as a failed one.
Retries
Two retries, after 5 minutes then 15 minutes — three attempts in total, after which the event is abandoned.
Retryable
Any 5xx, plus 408 and 429, plus no response at all. Other 4xx responses are treated as a permanent rejection and are not retried.
Rate
Up to 600 deliveries per minute per business.

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:

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:

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.

An expanded failed delivery with the Retry this delivery button on its attempt An expanded failed delivery with the Retry this delivery button on its attempt
The delivery is expanded to its attempt, which failed with a 502. Retry this delivery re-sends that one delivery and reports the response inline.

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.

Retry all failed showing a filtered count of seven Retry all failed showing a filtered count of seven
Narrowed to one event and the last 30 days, the Failed chip counts 7 — and Retry all failed carries the same 7. It re-sends those, not the whole subscription.
💡

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.

The Blocked filter with Retry all blocked The Blocked filter with Retry all blocked
With the Blocked filter selected, Retry all blocked re-sends the deliveries the platform refused to send. Blocked is the one status that cannot produce a duplicate.

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.

MessageWhat 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:

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.

StatusMeaning
PendingThe 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.
SucceededThe endpoint answered with a 2xx.
FailedA 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.
BlockedWe 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.
UnknownThe 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.

The events log The events log
Succeeded and Failed are derived from the response status rather than stored separately, so an attempt that timed out and recorded no response is correctly counted as failed.

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.

An expanded delivery showing the request payload above three collapsed attempts, with the second attempt (500) expanded to show its response body An expanded delivery showing the request payload above three collapsed attempts, with the second attempt (500) expanded to show its response body
Each attempt keeps its own response, rather than only the most recent one. This delivery was rate limited (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.

A successful test send A successful test send
The endpoint's status code, response time and body are reported back directly.
A failed test send A failed test send
A failure reports the underlying reason rather than a generic error — here, the connection was refused. Test sends report every failure this way; they don't distinguish a Blocked outcome the way the events log does for real deliveries.

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#

Editing a webhook Editing a webhook
The name, URL, basic-auth credentials and event selection can all be changed. The signing secret cannot be revealed or rotated here — it is fixed for the life of the subscription.

Deleting#

Deleting a webhook Deleting a webhook
Deleting stops delivery permanently and retires the secret. If you only want to pause, use the Enabled toggle instead.

Troubleshooting#

SymptomWhere to look
Nothing arrivesCheck 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 rightThe error text is reported verbatim. A resolution failure means DNS; a connection failure usually means the host is not publicly reachable.
Signature never matchesAlmost 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 3xxRedirects are not followed. Register the final URL.
Duplicate eventsExpected under at-least-once delivery. Deduplicate on idempotency_key. See Duplicates are expected.
Subscription auto-disabledThe 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">.

AnchorLands onGood link from
#overviewOverviewEmpty state of the Webhooks list
#creatingCreating a webhookThe Create webhook dialog
#creating-urlEndpoint URL rulesA URL validation error
#creating-eventsChoosing eventsThe events picker
#secretThe signing secretThe one-time secret dialog
#events-catalogFull event catalogue"Which events exist?"
#payload-envelopeThe envelopeAn expanded delivery's payload
#payload-headersHeadersHeader reference
#payload-catalogPayload by event“What does this event look like?”
#payload-<eventName>One event’s example bodyA delivery row, the events picker
#signatureVerifying the signatureNext to the secret, or a 401 from the endpoint
#signature-replayReplay protectionSecurity review notes
#deliveryDelivery & retriesA failed delivery row
#delivery-idempotencyDuplicatesSupport answer on double-processing
#delivery-resendRe-sending a deliverySupport answer on “how do I re-send this?”
#delivery-resend-buttonsWhich button to useThe Retry and Retry all buttons in the events log
#delivery-blockedBlocked deliveriesA “Blocked” delivery status, and the Blocked filter
#delivery-blocked-messagesWhat a blocked delivery tells youThe error message on an expanded Blocked attempt; a refused Retry
#delivery-auto-disableAuto-disableThe auto-disabled notification and status pill
#monitoringMonitoring deliveriesThe events log page
#monitoring-statusesDelivery statuses“What does status X mean?”
#monitoring-in-flightIn-flight deliveries“Why is there no Pending filter?”; API vs. dashboard reconciliation
#monitoring-retentionHistory retention“How far back does this go?”; a deleted subscription's deliveries page
#testingTesting an endpointThe test-fire result dialog
#managing-deleteDeletingThe delete confirmation
#troubleshootingTroubleshootingGeneric help link
#ts-signatureTroubleshooting: signature rowA signature-mismatch error
#ts-auto-disabledTroubleshooting: auto-disabled rowThe auto-disabled banner
#ts-blockedTroubleshooting: blocked status rowA run of “Blocked” deliveries
#ts-unknownTroubleshooting: unknown status rowAn “Unknown” delivery status

Rules for adding anchors#