Webhooks
Have Octocom POST a signed JSON body to your own endpoint whenever something happens — a bot reply, an agent reply, a handoff, a closed conversation. Configure them in the dashboard with no code: pick events, paste a URL, filter by business and channel.
A webhook POSTs a JSON body to a URL you control every time an event you subscribed to happens. Configure one in Settings → Automation → Event Handlers: create one, choose Webhook, paste an HTTPS endpoint, tick the events you care about, and save. No code.
Common uses:
- Forward bot and agent replies into a messaging channel you own (WhatsApp through your own BSP, an in-house app, an internal tool)
- Notify your CRM when a conversation is handed off or closed
- Stream conversation events into your own analytics or data warehouse
- Alert an on-call system when a rate limit starts dropping traffic, or when automated QA flags a reply as critical
Webhook or Python? Both live under Event Handlers and react to the same events; you pick which when you create one. Use a webhook if all you need is "send this event somewhere" — it is configuration, it retries on its own, and every attempt is logged. Use Python when the reaction needs logic: deciding, transforming, calling several systems, or writing back to the conversation.
Supported events
| Event | Fires when |
|---|---|
conversation_created | A new conversation starts |
conversation_closed | A conversation is closed, by an agent, the bot, or an automation |
conversation_handed_off | The bot hands a conversation to a human agent |
bot_message_sent | The bot wrote a reply. Once per message |
customer_message_sent | The customer sent a message. Once per message |
agent_message_sent | A human agent replied from the dashboard. Once per message |
campaign_recipient_unreachable | A phone-campaign recipient went terminally unreachable. No conversation unless the dial reached voicemail |
campaign_call_completed | A connected phone-campaign call finished and its transcript is available |
rate_limit_hit | A rate limit was exceeded and inbound traffic is being dropped. Never tied to a conversation |
bot_qa_result | An automated QA review of a bot-handled conversation finished |
One webhook can subscribe to any number of them. They all arrive at the same URL and carry the same payload shape, distinguished by the event field.
For two-way messaging, subscribe to bot_message_sent and agent_message_sent. From the customer's point of view they are the same thing — a reply — and the split exists only so you can log or route them differently. Handoff is then transparent: you do not have to detect it, and nothing about your handling changes. The only difference is that message.sender becomes "agent".
Filters
Beyond the event types, a webhook can narrow what reaches it. Everything here is optional, and leaving a filter empty means "don't filter on this" — nothing defaults to a restriction.
| Filter | What it does |
|---|---|
| Businesses | By default every business in your organization. Set Scope to "Only specific businesses" to limit it — the same mechanism custom actions use. The right way to give one brand its own endpoint. |
| Channels | Only conversations whose most recent channel is one of these. |
| Conversation tags | Fire when the conversation has any of the listed tags, or only when it has none of them. |
| Conversation metadata | One or more key/value conditions, all of which must match. Leave the value empty to match on the key being present at all. |
| Handoff state | Only while a human owns the conversation, or only while the bot does. |
| Customer contact details | Only when the customer has a phone number and/or an email — so you skip events you couldn't route a reply back to anyway. |
| Playground conversations | Excluded by default, so trying the bot internally cannot fire events at a live system. |
Events that have no conversation at all — rate_limit_hit, an unconnected campaign dial — ignore the conversation-based filters rather than being excluded by them. A webhook watching for rate limits isn't silently killed by an unrelated channel filter.
Filtering on your own data
Tags and metadata are the hooks for routing your own traffic. Both can be set when you create a conversation through the Conversation API:
{
"businessSlug": "your-business",
"messages": [{ "sender": "customer", "content": "When will it arrive?" }],
"tags": ["whatsapp", "latam"],
"metadata": { "source": "n8n", "orderNumber": "A-33915" }
}A webhook filtering on tags has any of [whatsapp], or on metadata source = n8n, then receives only the traffic that integration created — no need to inspect the payload and discard the rest at your end.
Tags can also be applied by agents and by other automations, so the same filter picks up a conversation that gets tagged later.
Payload
The body mirrors the context object our internal Python automations receive, so it is a well-trodden shape rather than something invented for webhooks.
{
"event": "agent_message_sent",
"sentAt": "2026-08-18T09:33:20.481Z",
"message": {
"id": "b71e4d90-33aa-4c58-9f21-0d5e77b1c3a2",
"sender": "agent",
"content": "Hi Maria, I've checked with the courier...",
"timestamp": "2026-08-18T09:33:19Z",
"channel": "api"
},
"conversation": {
"id": "9f2a41c8-6b0e-4d33-a1f9-77c2b0a4e511",
"publicId": "pWX6WC6zr",
"url": "https://app.octocom.ai/organization/your-org/conversation/pWX6WC6zr",
"subject": "Delivery timing",
"businessSlug": "your-business",
"isHandedOff": true,
"isPlayground": false,
"initialChannel": "api",
"latestChannel": "api",
"messages": [
{
"sender": "customer",
"content": "When will it arrive?",
"timestamp": "2026-08-18T09:12:44Z",
"channel": "api",
"files": []
},
{
"sender": "agent",
"content": "Hi Maria, I've checked with the courier...",
"timestamp": "2026-08-18T09:33:19Z",
"channel": "api",
"files": []
}
]
},
"business": { "name": "Your Business", "slug": "your-business" },
"customer": {
"id": "2d77c1b4-8e05-4b6a-9f13-6c1a0e9d4477",
"name": "Maria Gonzalez",
"email": null,
"phone": "+52 55 1234 5678"
}
}| Field | Type | Notes |
|---|---|---|
event | string | Which event fired |
sentAt | string | When we built the payload (ISO 8601) |
message | object | The one message this event is about. All you need for the happy path. null for events without a message |
message.sender | string | bot, agent or customer |
message.id | uuid | Stable per message — use it to deduplicate retries |
conversation | object | null for rate_limit_hit and for campaign events where no dial connected |
conversation.messages | array | The full thread to date, for context. Ignore it if you only need the new message |
conversation.messages[].files | array | Attachments on that message — id, name, contentType, url, isSafe |
conversation.assignee | object | The agent currently assigned, null when unassigned |
conversation.messages[].agentName | string | Set on agent-sent messages, null otherwise. agentEmail sits alongside it |
conversation.url | string | Deep link to the conversation in the Octocom dashboard |
customer.phone | string | Present when we have it, so you can route without your own lookup |
args | object | Only on events that carry extra detail — rate_limit_hit, bot_qa_result, the campaign events |
You can see the exact body for your own configuration, and send a real test delivery, from Payload and test delivery on the webhook's edit page.
Verifying that a request came from us
Every delivery carries a signature computed with the webhook's signing secret, which you'll find on its edit page in the dashboard.
X-Octocom-Signature: sha256=<hex digest of the raw body>
X-Octocom-Event: agent_message_sent
X-Octocom-Delivery: 1f4b9e07-2c88-4f0a-b3d1-9a6e5c0f8d21
X-Octocom-Timestamp: 2026-08-18T09:33:20.481ZCompute an HMAC-SHA256 of the raw request body with the secret and compare in constant time.
const crypto = require("crypto");
function verify(rawBody, signatureHeader, secret) {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader),
);
}import hashlib
import hmac
def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)Use the raw body bytes, not a re-serialised object. Re-serialising changes key order and whitespace, and the digest will never match.
You can rotate the secret at any time from the same page. Deliveries are signed with the new secret immediately, so update the receiving end first.
Delivery, retries and ordering
- We expect a 2xx response within 10 seconds. Anything else — a non-2xx status, a connection error, or no answer within that window — counts as a failed attempt.
- Failed deliveries are retried with exponential backoff — after 1 minute, 5 minutes, 15 minutes, 1 hour and 6 hours. After five failed attempts the delivery is marked undelivered and no longer retried.
- Acknowledge first, work after. Those ten seconds are the whole budget for your endpoint, so don't spend them calling another system. Return the 2xx, then do the real work — anything you do after responding has no time limit from us.
- Make your handler tolerant of duplicates. A retry can arrive after a response we never saw, so the same
message.idmay be delivered more than once. - Events are dispatched in order, but retries mean you should not assume they arrive in order.
message.timestampis authoritative. - A webhook whose deliveries keep failing is auto-disabled after 10 consecutive undelivered events, so a decommissioned endpoint doesn't accumulate retries forever. The list page shows it as Auto-disabled; fix the endpoint and switch it back on, which also resets the counter.
Every attempt — status, response code, response body, and the exact payload sent — is recorded on the webhook's Deliveries tab. You can re-send any past delivery by hand from there; it replays the body as originally built rather than rebuilding it from current state.
Two-way messaging
If you own the channel — your own WhatsApp number through your own provider, an in-house app — you can run the whole conversation over HTTP: push customer messages in with the Conversation API, and receive every reply on a webhook.
- The customer sends you a message.
- Your middleware calls
POST /rest/v1/conversations(first message in a thread) orPOST /rest/v1/conversations/{id}/messages(every reply after that). We store it and answer immediately with the conversation id — you never wait for the bot. - The bot answers. If it needs a human, it hands the conversation to an agent, who replies from the dashboard.
- Either way we POST the reply to your webhook, with
message.senderset tobotoragent. - You send it on to the customer.
Store the conversationId we return against your own thread identity — it's the key that ties the webhooks back to your customer.