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

EventFires when
conversation_createdA new conversation starts
conversation_closedA conversation is closed, by an agent, the bot, or an automation
conversation_handed_offThe bot hands a conversation to a human agent
bot_message_sentThe bot wrote a reply. Once per message
customer_message_sentThe customer sent a message. Once per message
agent_message_sentA human agent replied from the dashboard. Once per message
campaign_recipient_unreachableA phone-campaign recipient went terminally unreachable. No conversation unless the dial reached voicemail
campaign_call_completedA connected phone-campaign call finished and its transcript is available
rate_limit_hitA rate limit was exceeded and inbound traffic is being dropped. Never tied to a conversation
bot_qa_resultAn 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.

FilterWhat it does
BusinessesBy 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.
ChannelsOnly conversations whose most recent channel is one of these.
Conversation tagsFire when the conversation has any of the listed tags, or only when it has none of them.
Conversation metadataOne or more key/value conditions, all of which must match. Leave the value empty to match on the key being present at all.
Handoff stateOnly while a human owns the conversation, or only while the bot does.
Customer contact detailsOnly 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 conversationsExcluded 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"
  }
}
FieldTypeNotes
eventstringWhich event fired
sentAtstringWhen we built the payload (ISO 8601)
messageobjectThe one message this event is about. All you need for the happy path. null for events without a message
message.senderstringbot, agent or customer
message.iduuidStable per message — use it to deduplicate retries
conversationobjectnull for rate_limit_hit and for campaign events where no dial connected
conversation.messagesarrayThe full thread to date, for context. Ignore it if you only need the new message
conversation.messages[].filesarrayAttachments on that message — id, name, contentType, url, isSafe
conversation.assigneeobjectThe agent currently assigned, null when unassigned
conversation.messages[].agentNamestringSet on agent-sent messages, null otherwise. agentEmail sits alongside it
conversation.urlstringDeep link to the conversation in the Octocom dashboard
customer.phonestringPresent when we have it, so you can route without your own lookup
argsobjectOnly 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.481Z

Compute 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.id may be delivered more than once.
  • Events are dispatched in order, but retries mean you should not assume they arrive in order. message.timestamp is 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.

  1. The customer sends you a message.
  2. Your middleware calls POST /rest/v1/conversations (first message in a thread) or POST /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.
  3. The bot answers. If it needs a human, it hands the conversation to an agent, who replies from the dashboard.
  4. Either way we POST the reply to your webhook, with message.sender set to bot or agent.
  5. 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.

On this page