Receiving webhooks
Subscribe a URL and receive signed, real-time events from your bots — new conversations, messages and escalations.
BotDesk can push signed HTTP events to your own endpoint as things happen in a conversation — a new conversation starts, a message is persisted, or the bot escalates to a human. This turns the public API from pull-only into a real integration surface: instead of polling, you receive events as they occur.
Outbound webhooks are available on the Premium plan.
Authentication
All management endpoints authenticate with your public API key in the
X-API-Key header (the same key used by the rest of the public API). Each
endpoint is scoped to your tenant — you can only ever see or modify your own
subscriptions.
Managing subscriptions
Base path: /v1/public/v1/webhooks
| Method | Path | Purpose |
|---|---|---|
POST |
/webhooks |
Create a subscription. Returns the signing secret once. |
GET |
/webhooks |
List your subscriptions. |
PATCH |
/webhooks/{id} |
Update url, event_types, or is_active. |
DELETE |
/webhooks/{id} |
Delete a subscription. |
POST |
/webhooks/{id}/rotate-secret |
Generate a new signing secret. Returns it once. |
GET |
/webhooks/{id}/deliveries |
Inspect delivery history (status, response code, attempts). |
Creating a subscription
POST /v1/public/v1/webhooks
X-API-Key: <your-api-key>
Content-Type: application/json
{
"url": "https://your-app.example.com/botdesk/webhooks",
"event_types": ["conversation.created", "message.created", "escalation.requested"]
}
Response (201 Created):
{
"id": "…",
"url": "https://your-app.example.com/botdesk/webhooks",
"event_types": ["conversation.created", "message.created", "escalation.requested"],
"is_active": true,
"created_at": "…",
"updated_at": "…",
"secret": "whsec_…"
}
urlmust be HTTPS and must resolve to a public address. URLs pointing at loopback, link-local, private, or otherwise reserved ranges are rejected with422(SSRF protection), both at creation and on any later URL change.- Only the three event types below are accepted; an unknown value returns
422. secretis returned exactly once, here and onrotate-secret. Store it securely — it is used to verify signatures and is never shown again.
Events
Every delivery is a JSON POST with a versioned envelope:
{
"event": "message.created",
"event_id": "b3f1c2a4-…",
"version": "v1",
"created_at": "2026-07-29T12:34:56.789012+00:00",
"data": { }
}
| Field | Meaning |
|---|---|
event |
The event type (one of the three below). |
event_id |
Stable id for this logical event. Use it to dedupe (see below). |
version |
Envelope version, currently v1. |
created_at |
ISO-8601 UTC timestamp of when the event was rendered. |
data |
Event-specific payload. |
conversation.created
Fires when a new conversation is created on any channel (widget, WhatsApp, Telegram, Instagram).
{ "conversation_id": "…", "channel": "whatsapp", "bot_id": "…" }
message.created
Fires whenever a message is persisted in a conversation, for both the inbound user turn and the outbound bot turn.
{ "conversation_id": "…", "message_id": "…", "role": "user", "content": "…" }
role is user or assistant. Note: for the widget channel the assistant
message is persisted first as an empty placeholder and its text is finalized a
moment later; the event carries the placeholder body at persist time. Reconcile
on message_id if you need the finalized reply.
escalation.requested
Fires when the bot escalates a conversation to a human (explicit or implicit
trigger). contact is included when contact information was captured.
{ "conversation_id": "…", "contact": { "email": "customer@example.com" } }
Verifying signatures
Every delivery carries these headers:
| Header | Value |
|---|---|
X-BotDesk-Signature |
sha256=<hex> — HMAC-SHA256 of the signed message. |
X-BotDesk-Timestamp |
Unix timestamp (seconds) used in the signed message. |
X-BotDesk-Event |
The event type, mirroring event in the body. |
X-BotDesk-Delivery |
Unique id for this delivery attempt. |
The signed message is the timestamp, a literal ., and the raw request
body:
signed_message = f"{X-BotDesk-Timestamp}." + <raw request body bytes>
expected = "sha256=" + HMAC_SHA256(secret, signed_message).hexdigest()
Compare expected against X-BotDesk-Signature using a constant-time
comparison. Sign against the raw bytes you received — do not re-serialize
the JSON first, or whitespace/key-ordering differences will break the check.
Reject deliveries whose X-BotDesk-Timestamp is outside your tolerance (e.g.
±5 minutes) to bound replay.
Python example:
import hashlib
import hmac
def verify(secret: str, headers: dict, raw_body: bytes) -> bool:
timestamp = headers["X-BotDesk-Timestamp"]
message = f"{timestamp}.".encode() + raw_body
expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, headers["X-BotDesk-Signature"])
Delivery, retries, and deduplication
- Success is any
2xxresponse. Respond quickly (within ~10 seconds) and do slow work asynchronously — BotDesk uses a short timeout and does not follow redirects. - Retries use a fixed backoff schedule after the first attempt:
0s, 1m, 5m, 30m, 2h, 6h. After 6 failed attempts the delivery is marked dead and is not retried again. Inspect state viaGET /webhooks/{id}/deliveries. - Deduplicate on
event_id. The same logical event carries one stableevent_idacross every retry, and across every subscription it fans out to. Treat delivery as at-least-once: an event may arrive more than once, so make your handler idempotent keyed onevent_id. - Ordering is not guaranteed. Use
created_at/message_idif you need to order events on your side.
Rotating and disabling
POST /webhooks/{id}/rotate-secretissues a new secret and invalidates the old one. Update your verifier before (or immediately after) rotating.- Set
is_active: falseviaPATCHto pause deliveries without deleting the subscription; set it back totrueto resume.