API reference
Read and reply to your WhatsApp, Messenger and Instagram conversations, keep contacts in sync, and have events sent to your own server as they happen.
Overview
The base URL for every request:
https://omniochat.com/api/v1
- Plans. The API and webhooks are included on the Growth and Business plans, and during the free trial. On Starter, requests are refused with
402 upgrade_required. - JSON. Send
Content-Type: application/jsonandAccept: application/json, except for file uploads, which aremultipart/form-data. - Versioning. The version is in the path. Within v1 we only add — new endpoints, new fields, new events, new
reasoncodes — so ignore fields you do not recognise rather than rejecting them. - Rate limit. 120 requests a minute per user. Over it you get
429with aRetry-Afterheader.
Quick start
Create a token under Automate → API & webhooks in the app, then:
# Who am I?
curl https://YOUR-DOMAIN/api/v1/me \
-H "Authorization: Bearer $TOKEN" -H "Accept: application/json"
# The five most recent open conversations
curl "https://YOUR-DOMAIN/api/v1/conversations?status=open&per_page=5" \
-H "Authorization: Bearer $TOKEN" -H "Accept: application/json"
# Reply to one
curl -X POST https://YOUR-DOMAIN/api/v1/conversations/CONVERSATION_ID/reply \
-H "Authorization: Bearer $TOKEN" -H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"content": "Thanks — it shipped this morning."}'
Authentication
Every request carries a token: Authorization: Bearer <token>.
Owners and admins create tokens on the API & webhooks settings page. A token is shown once — store it then.
A token acts as the person who created it, and sees exactly what they see: an agent’s token only reaches the conversations that agent can open in the inbox. It is also limited to its scopes, chosen when it is made. Give each integration its own token with only the scopes it needs, and an expiry, so one leak can be revoked without touching the rest.
| Scope | Allows |
|---|---|
| * | Everything, including scopes added later |
| conversations:read | Read conversations, their messages and notes |
| conversations:write | Assign, resolve, label and add notes to conversations |
| messages:write | Send messages and template messages as the business |
| contacts:read | Read contacts and search them |
| contacts:write | Create, update and delete contacts |
| webhooks:read | List webhook endpoints and their delivery history |
| webhooks:write | Create, update and delete webhook endpoints |
| analytics:read | Read usage counters and reporting totals |
A request without the scope it needs gets 403 naming the scope it was missing and the scopes the token does have.
GET /me needs no scope, so any token can ask what it is allowed to do.
Requests & responses
- Successful responses wrap their result in
data. - Lists are paginated with
pageandper_page(at most 100) and carrymeta:current_page,per_page,total,last_page. - Times are ISO 8601 in UTC. Conversation and message ids are 24-character hex strings; contacts, webhook endpoints and deliveries use UUIDs; users, teams, labels, tags and templates use integers.
- Something that does not exist and something your token may not see both answer
404, so an id cannot be used to learn what exists.
{
"data": [
{
"id": "…"
},
{
"id": "…"
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 112,
"last_page": 5
}
}
Errors
Every error has a human-readable message. Validation errors (422) add
errors, keyed by field. Refusals you can act on add a stable, machine-readable reason —
branch on that, never on the message text.
{
"message": "This chat is outside WhatsApp's 24-hour window. Send an approved template to re-engage the contact.",
"reason": "window_closed"
}
| Status | reason | Meaning |
|---|---|---|
| 401 | — | Missing, expired or revoked token. |
| 403 | — | The token lacks a scope (the body names it as required_scope), or the user’s role does not allow the action. |
| 404 | — | Not found, or not visible to this token. |
| 422 | — | Validation failed; see errors. |
| 429 | — | Rate limited; wait for Retry-After seconds. |
| 402 | upgrade_required | The workspace’s plan does not include API access (Growth and Business do, and so does the free trial). |
| 402 | read_only | The trial has ended without a plan being chosen. Reads still work; writes do not. |
| 403 | tenant_suspended | The workspace has been suspended. Contact support. |
| 422 | window_closed | Outside WhatsApp’s 24-hour customer service window (or Messenger/Instagram’s 7 days). Only an approved template can be sent — use POST …/template. |
| 422 | not_open | The conversation is resolved or snoozed. Reopen it before replying. |
| 422 | not_whatsapp | Templates exist only on WhatsApp. |
| 404 | template_not_found | No such template on this conversation’s WhatsApp number. |
| 422 | template_not_approved | The template has not been approved by Meta yet. |
| 422 | variable_count | The number of variables does not match the template body’s placeholders. |
| 422 | header_required | The template has a header that needs filling: header_variables for a text header, or an https header_media_url for an image, video or document header. |
| 422 | button_value_required | A button needs a value in button_values: the end of a URL button’s link, or a copy-code button’s code. |
| 422 | unsupported_media | The file type cannot be sent on this channel. |
| 409 | duplicate_contact | Another contact already has that phone number or email. The response carries its existing_id. |
| 422 | url_refused | The webhook URL is not https, or does not resolve to a public address. |
| 422 | endpoint_limit | The workspace already has the maximum of 10 webhook endpoints. |
| 422 | endpoint_disabled | The webhook endpoint is turned off. Turn it back on (enabled: true) before resending or testing it. |
| 422 | delivery_in_progress | That delivery is still being attempted; resend it once it has delivered or failed. |
Objects
The same shapes are used in responses and in webhook payloads.
conversation
Lists return the short form, up to last_message_preview; a single conversation returns all of it. can_send_freeform is whether a text reply will go now — when it is false, send a template.
{
"id": "6710c2f1a9e4b2d7c8f30a11",
"channel_type": "whatsapp",
"status": "open",
"contact": {
"name": "Sarah Ahmed",
"external_id": "447700900123"
},
"assigned_to": 42,
"assigned_team": 3,
"unread_count": 2,
"last_message_at": "2026-10-18T09:14:03+00:00",
"last_message_preview": "Has my order shipped yet?",
"participants": [
42,
57
],
"label_ids": [
5
],
"bot_enabled": false,
"last_inbound_at": "2026-10-18T09:14:03+00:00",
"whatsapp_window_open": true,
"can_send_freeform": true
}
message
direction is inbound or outbound. Outbound status moves pending → sent → delivered → read, or to failed. sent_by is the user who sent it, null for the customer.
{
"id": "6710c2f1a9e4b2d7c8f30b27",
"conversation_id": "6710c2f1a9e4b2d7c8f30a11",
"direction": "inbound",
"type": "text",
"content": "Has my order shipped yet?",
"media_url": null,
"status": "received",
"sent_by": null,
"created_at": "2026-10-18T09:14:03+00:00"
}
note
{
"id": "6710c3a8a9e4b2d7c8f30c02",
"body": "Asked for a refund last month too.",
"user_id": 42,
"user_name": "Dan Okafor",
"created_at": "2026-10-18T09:20:44+00:00"
}
contact
{
"id": "9b1f6a52-3c1e-4c55-9a2e-6f0f1d2b7a40",
"first_name": "Sarah",
"last_name": "Ahmed",
"full_name": "Sarah Ahmed",
"phone_e164": "+447700900123",
"email": "sarah@example.com",
"channel_type": "whatsapp",
"country": "GB",
"language": "en",
"opt_in_whatsapp": true,
"opt_in_sms": false,
"opt_in_email": true,
"custom_fields": {
"plan": "gold",
"seats": 4
},
"tags": [
{
"id": 7,
"name": "VIP",
"color": "#7c3aed"
}
],
"source": "inbound",
"last_seen_at": "2026-10-18T09:14:03+00:00",
"created_at": "2026-03-02T11:05:19+00:00"
}
Account
Who the token belongs to, and how much of the plan the workspace has used.
/me
The token’s user, workspace and scopes
Needs no scope, so any valid token can ask what it is and what it may do. token.abilities is the token’s own list, which can be narrower than its owner’s role. tenant.read_only is true when the trial has ended without a plan — show your UI as read-only rather than waiting for a 402. Still answers on Starter, so a client can tell the user to upgrade.
Returns data: id, name, email, role, tenant { id, name, plan, subscription_status, read_only }, token { name, abilities, expires_at }.
/devices
conversations:read
Register a phone for push notifications
For the mobile app (Firebase Cloud Messaging). Call after signing in, and again from firebase_messaging’s onTokenRefresh. The device is tied to the session that registered it: signing out or revoking that session stops its pushes. Pushes carry data.type (message_received or conversation_assigned) and data.conversation_id to open on tap; per-user opt-outs are the mobile_push channel in notification settings.
| Parameter | In | Type | |
|---|---|---|---|
| token* | body | string | The FCM registration token (FirebaseMessaging.instance.getToken()). |
| platform* | body | string | android or ios. |
| app_version | body | string | For support, e.g. 1.4.0. |
Returns 201 for a new device, 200 when re-registered; data: { id, platform, registered_at }.
/devices
conversations:read
Stop pushes to a phone
When the user turns notifications off in the app. Signing out does this on its own.
| Parameter | In | Type | |
|---|---|---|---|
| token* | body | string | The FCM token to remove. |
Returns 204.
/broadcasting/auth
conversations:read
Authorise a realtime subscription
For live updates over our Pusher-protocol WebSocket. Point your Pusher client’s auth endpoint here, with the token in its auth headers. A user may subscribe to private-agent.{their user id}, which carries new messages, status ticks and assignment changes for everything they can see. Any other channel is refused.
| Parameter | In | Type | |
|---|---|---|---|
| socket_id* | body | string | From the client’s connection. |
| channel_name* | body | string | e.g. private-agent.42 |
Returns { auth }: the signature the client hands to the WebSocket server.
/usage
analytics:read
Usage against the plan
The figures the billing page shows. Owners and admins only — an agent’s token is refused even with the scope.
Returns data: plan, channels { used, limit }, seats { used, limit, included, purchased }, monthly { period (YYYY-MM), meters { <meter>: { used, limit } } }. A limit of null means uncapped on this plan. Meters: ai_tokens_per_month, campaigns_per_month, campaign_messages_per_month, lead_credits_per_month, social_posts_per_month.
Conversations
The shared inbox. A token sees exactly what its owner sees in the web inbox: owners and admins see every conversation; an agent sees those assigned to them, to one of their teams, or that they have joined. A conversation the token cannot see answers 404, the same as one that does not exist.
/conversations
conversations:read
List conversations
Most recent activity first.
| Parameter | In | Type | |
|---|---|---|---|
| status | query | string | open, resolved or pending. |
| channel_type | query | string | whatsapp, messenger or instagram. |
| assignment | query | string | mine, unassigned or all (default). |
| per_page | query | integer | Page size, 1–100. Default 25 unless stated. |
| page | query | integer | Page number, from 1. |
Returns A page of conversation objects (the short form: without participants … can_send_freeform).
/conversations/{id}
conversations:read
Get a conversation
The full form. can_send_freeform says whether a free-text reply will go now; if it is false, send a template.
| Parameter | In | Type | |
|---|---|---|---|
| id* | path | string | Conversation id. |
Returns data: a conversation object.
/conversations/{id}/messages
conversations:read
List messages
Newest first, stable across pages even for messages written in the same second.
| Parameter | In | Type | |
|---|---|---|---|
| id* | path | string | Conversation id. |
| per_page | query | integer | 1–100. Default 50. |
| page | query | integer | Page number, from 1. |
Returns A page of message objects.
/messages/{messageId}/media
conversations:read
Download a message’s file
The URL every media_url points at. Send the same Authorization header. Images, video and audio are served inline; everything else as an attachment.
| Parameter | In | Type | |
|---|---|---|---|
| messageId* | path | string | Message id. |
Returns The file, with its content type.
/conversations/{id}/assign
conversations:write
Assign
Only the fields sent change; null unassigns. Send at least one. Agents who lose the conversation see it leave their inbox immediately.
| Parameter | In | Type | |
|---|---|---|---|
| id* | path | string | Conversation id. |
| user_id | body | integer|null | A member of this workspace. |
| team_id | body | integer|null | A team in this workspace. |
Returns data: the conversation.
/conversations/{id}/resolve
conversations:write
Resolve
Safe to retry: resolving a resolved conversation succeeds.
| Parameter | In | Type | |
|---|---|---|---|
| id* | path | string | Conversation id. |
Returns data: the conversation.
/conversations/{id}/reopen
conversations:write
Reopen
Safe to retry.
| Parameter | In | Type | |
|---|---|---|---|
| id* | path | string | Conversation id. |
Returns data: the conversation.
/conversations/{id}/notes
conversations:read
List internal notes
Agent-only notes, oldest first. Never sent to the customer.
| Parameter | In | Type | |
|---|---|---|---|
| id* | path | string | Conversation id. |
Returns data: note objects.
/conversations/{id}/notes
conversations:write
Add an internal note
Written as the token’s user.
| Parameter | In | Type | |
|---|---|---|---|
| id* | path | string | Conversation id. |
| body* | body | string | Up to 4096 characters. |
Returns 201, data: the note.
/labels
conversations:read
List conversation labels
The workspace’s labels, to turn label_ids into names.
Returns data: [{ id, name, color }].
/conversations/{id}/labels
conversations:write
Add a label
Adding a label the conversation already has succeeds without duplicating it.
| Parameter | In | Type | |
|---|---|---|---|
| id* | path | string | Conversation id. |
| label_id* | body | integer | A label in this workspace. |
Returns data: { label_ids }.
/conversations/{id}/labels/{labelId}
conversations:write
Remove a label
| Parameter | In | Type | |
|---|---|---|---|
| id* | path | string | Conversation id. |
| labelId* | path | integer | Label id. |
Returns data: { label_ids }.
Sending messages
Sending needs messages:write, separate from everything else, because it speaks for the business. A send is queued, not delivered: the response says status: pending, and the message moves to sent, delivered, read or failed afterwards — subscribe to message.status_updated to follow it. The first reply to an unassigned conversation assigns it to the token’s user, as in the inbox.
/conversations/{id}/reply
messages:write
Reply with text
Free text is allowed only inside the channel’s window. Outside it you get window_closed, and only a template will go.
| Parameter | In | Type | |
|---|---|---|---|
| id* | path | string | Conversation id. |
| content* | body | string | Up to 4096 characters. |
Returns 201, data: the message.
Refusals window_closed, not_open
/conversations/{id}/media
messages:write
Send a file
multipart/form-data. Up to 16 MB. Images (JPEG, PNG, WebP, GIF), video (MP4, 3GP), audio (AAC, AMR, MP3, M4A, OGG), and documents (PDF, Word, Excel, PowerPoint, plain text). The same window rules as text.
| Parameter | In | Type | |
|---|---|---|---|
| id* | path | string | Conversation id. |
| file* | body | file | The file. |
| caption | body | string | Up to 1024 characters. |
Returns 201, data: the message, with media_url.
Refusals window_closed, not_open, unsupported_media
/conversations/{id}/templates
conversations:read
List sendable templates
The approved WhatsApp templates on this conversation’s number, with how many variables each takes. Empty on Messenger and Instagram.
| Parameter | In | Type | |
|---|---|---|---|
| id* | path | string | Conversation id. |
Returns data: [{ id, name, language, category, variable_count, requirements, components }]. requirements says what a send needs: body_variables (count), header ({ format: TEXT/IMAGE/VIDEO/DOCUMENT, variables } or null), buttons ([{ index, type, text, needs: url_suffix / coupon_code / null }]).
/conversations/{id}/template
messages:write
Send a template
The way to message a customer outside the 24-hour window. Unlike a text reply it may be sent to a resolved conversation (it does not reopen it). Check the template’s requirements (GET …/templates): variables fill the body’s {{1}}, {{2}}… and must number exactly body_variables; a header or a dynamic button needs its value too.
| Parameter | In | Type | |
|---|---|---|---|
| id* | path | string | Conversation id. |
| template_id* | body | integer | From GET …/templates. |
| variables | body | string[] | Body placeholders, in order; each up to 1024 characters. |
| header_variables | body | string[] | A text header’s placeholders, in order. |
| header_media_url | body | string | An https link to the image, video or document, for a media header. Meta fetches it. |
| button_values | body | object | Button index → value: the end of a URL button’s link, a copy-code button’s code, or (optional) a quick reply’s payload, which comes back as metadata.reply.id when tapped. |
Returns 201, data: the message, with the filled-in header and body as content.
Refusals not_whatsapp, template_not_found, template_not_approved, variable_count, header_required, button_value_required
Contacts
Contacts are shared across the workspace. They are addressed by id (a UUID).
/contacts
contacts:read
List and search contacts
Most recently seen first.
| Parameter | In | Type | |
|---|---|---|---|
| search | query | string | Matches first name, last name, phone or email. |
| tag_id | query | integer | Only contacts with this tag. |
| per_page | query | integer | Page size, 1–100. Default 25 unless stated. |
| page | query | integer | Page number, from 1. |
Returns A page of contact objects.
/contacts/{uuid}
contacts:read
Get a contact
| Parameter | In | Type | |
|---|---|---|---|
| uuid* | path | string | Contact id. |
Returns data: a contact object.
/contacts
contacts:write
Create or update a contact
An upsert, so a sync can send the same person twice: matched on phone_e164, then email. 201 when a contact is created, 200 when an existing one is updated. An existing contact’s source is never changed.
| Parameter | In | Type | |
|---|---|---|---|
| phone_e164 | body | string | E.164, e.g. +447700900123. This or email is required. |
| body | string | This or phone_e164 is required. |
|
| first_name | body | string | Up to 120 characters. |
| last_name | body | string | Up to 120 characters. |
| country | body | string | Two-letter ISO code. |
| language | body | string | e.g. en, en-GB. |
| opt_in_whatsapp | body | boolean | |
| opt_in_sms | body | boolean | |
| opt_in_email | body | boolean | |
| custom_fields | body | object | Flat: up to 50 keys, each a string (≤1000 characters), number or boolean. |
| tag_ids | body | integer[] | Tags in this workspace. |
Returns 201 or 200, data: the contact.
/contacts/{uuid}
contacts:write
Update a contact
Only the fields sent change. Moving a contact onto a phone number or email another contact already has is refused, because those are what creates match on. A contact must keep a phone number or an email.
| Parameter | In | Type | |
|---|---|---|---|
| uuid* | path | string | Contact id. |
| … | body | Any field from create except tag_ids. |
Returns data: the contact.
Refusals duplicate_contact
/contacts/{uuid}
contacts:write
Delete a contact
If the same person messages again they are restored rather than created afresh.
| Parameter | In | Type | |
|---|---|---|---|
| uuid* | path | string | Contact id. |
Returns 204.
Webhook endpoints
Manage where events are sent. Owners and admins only, whatever the token’s scopes: an endpoint receives every event in the workspace, so creating one is the same as being able to read the whole inbox.
/webhook-events
webhooks:read
List subscribable events
Returns data: [{ event, description }].
/webhooks
webhooks:read
List endpoints
The signing secret is never included.
Returns data: [{ id, url, events, enabled, disabled_reason, disabled_at, consecutive_failures, description, created_at }]. disabled_reason is failing when we turned the endpoint off, null otherwise.
/webhooks
webhooks:write
Create an endpoint
The response includes secret — the only time it is shown. Store it to verify signatures.
| Parameter | In | Type | |
|---|---|---|---|
| url* | body | string | https, resolving to a public address. Up to 500 characters. |
| events* | body | string[] | Event names, or ["*"] for all, including events added later. An empty list is refused. |
| description | body | string | Up to 200 characters. |
| enabled | body | boolean | Default true. |
Returns 201, data: the endpoint plus secret.
Refusals url_refused, endpoint_limit
/webhooks/{uuid}
webhooks:read
Get an endpoint
| Parameter | In | Type | |
|---|---|---|---|
| uuid* | path | string | Endpoint id. |
Returns data: the endpoint.
/webhooks/{uuid}
webhooks:write
Update an endpoint
Any of url, events, description, enabled. Set enabled: false to pause deliveries without losing the endpoint. Setting enabled: true on an endpoint we turned off clears its failure count.
| Parameter | In | Type | |
|---|---|---|---|
| uuid* | path | string | Endpoint id. |
Returns data: the endpoint.
Refusals url_refused
/webhooks/{uuid}
webhooks:write
Delete an endpoint
Pending retries are cancelled and the delivery history is removed.
| Parameter | In | Type | |
|---|---|---|---|
| uuid* | path | string | Endpoint id. |
Returns 204.
/webhooks/{uuid}/rotate-secret
webhooks:write
Rotate the signing secret
Takes effect immediately, with no overlap — deliveries are signed with the new secret from the next one.
| Parameter | In | Type | |
|---|---|---|---|
| uuid* | path | string | Endpoint id. |
Returns data: the endpoint plus the new secret.
/webhooks/{uuid}/test
webhooks:write
Send a test event
Queues a webhook.ping through the real delivery path — signing, retries and all.
| Parameter | In | Type | |
|---|---|---|---|
| uuid* | path | string | Endpoint id. |
Returns 202, data: the delivery.
Refusals endpoint_disabled
/webhooks/{uuid}/deliveries
webhooks:read
Delivery history
Newest first. Kept for 30 days.
| Parameter | In | Type | |
|---|---|---|---|
| uuid* | path | string | Endpoint id. |
| per_page | query | integer | Page size, 1–100. Default 25 unless stated. |
| page | query | integer | Page number, from 1. |
Returns A page of deliveries: id, event_id, event, state (pending, retrying, delivered, failed), attempts, response_status, response_body, error, payload, delivered_at, failed_at, next_retry_at, created_at.
/webhooks/{uuid}/deliveries/{deliveryUuid}/redeliver
webhooks:write
Resend a delivery
Sends a past event again — after fixing your receiver, say — as a new delivery with the same event_id, so a receiver that de-duplicates on X-Webhook-Id treats it as the event it already knows. Only once the original has delivered or failed, and only while the endpoint is on.
| Parameter | In | Type | |
|---|---|---|---|
| uuid* | path | string | Endpoint id. |
| deliveryUuid* | path | string | Delivery id, from the history. |
Returns 202, data: the new delivery.
Refusals delivery_in_progress, endpoint_disabled
Tokens
Most people create tokens on the API & webhooks settings page. These endpoints do the same over the API, for owners and admins, and only with a full-access token — a narrow token cannot make a wider one.
/token-scopes
List scopes
Returns data: [{ scope, description }].
/tokens
List your tokens
Tokens belong to a user; this lists the caller’s own.
Returns data: [{ id, name, abilities, last_used_at, expires_at, created_at }].
/tokens
Create a token
The plaintext token is returned once and cannot be recovered.
| Parameter | In | Type | |
|---|---|---|---|
| name* | body | string | Up to 120 characters. |
| abilities | body | string[] | Scopes, or ["*"]. Default ["*"]. |
| expires_at | body | datetime | In the future. Omit for no expiry (not recommended). |
Returns 201, token and data: the token.
/tokens/{token}
Revoke a token
Works in any workspace state, so a token you think has leaked can always be revoked.
| Parameter | In | Type | |
|---|---|---|---|
| token* | path | integer | Token id. |
Returns { deleted: true }.
Mobile sign-in
For our mobile app. Integrations should use a token from the settings page instead: these sessions last 90 days and are tied to a person’s password.
/auth/login
Sign in
No token needed. Refused for a Starter workspace (the app comes with Growth and Business, and the trial). After many failed attempts the account is locked for 15 minutes, and one address is limited to 20 attempts a minute. A wrong password and an unknown email get the same answer.
| Parameter | In | Type | |
|---|---|---|---|
| email* | body | string | |
| password* | body | string | |
| device_name* | body | string | Shown in the token list, e.g. “Sarah’s iPhone”. |
Returns token, user { id, name, email, role, tenant_id }, expires_at.
/auth/logout
Sign out
Revokes only the token that made the call; other devices stay signed in.
Returns { ok: true }.
Webhooks
Subscribe a URL to events and we POST each one to it as JSON, signed so you can tell it came from us. Set them up on the settings page or with the webhook endpoints API.
Events
| message.received | A customer sent a message |
| message.status_updated | An outbound message was sent, delivered, read, or failed |
| conversation.created | A new conversation started |
| conversation.assigned | A conversation was assigned to a different agent or team |
| conversation.status_changed | A conversation was resolved, reopened, or snoozed |
| contact.created | A contact was added, by a message, an import, the API or by hand |
| contact.updated | A contact’s details changed (not just their last-seen time) |
| campaign.completed | A campaign finished sending to its whole audience |
data holds the resource in its API shape: message for message events,
conversation (full form) for conversation events, contact for contact events.
message.status_updated and conversation.status_changed add
previous_status; conversation.assigned adds previous
{ assigned_to, assigned_team }; contact.updated adds
changed, the fields that changed. campaign.completed carries a campaign:
id, name, channel, status,
total, sent, failed, launched_at,
completed_at. A test sends webhook.ping.
The request
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: OmnioChat-Webhooks/1.0
X-Webhook-Event: message.received
X-Webhook-Id: 0f3c9a7e-5d21-4b8e-9a64-2c1e7b9d4f10
X-Webhook-Signature: t=1792271957,v1=5f2b…
{
"id": "0f3c9a7e-5d21-4b8e-9a64-2c1e7b9d4f10",
"event": "message.received",
"created_at": "2026-10-18T09:14:04+00:00",
"data": {
"message": {
"id": "6710c2f1a9e4b2d7c8f30b27",
"conversation_id": "6710c2f1a9e4b2d7c8f30a11",
"direction": "inbound",
"type": "text",
"content": "Has my order shipped yet?",
"media_url": null,
"status": "received",
"sent_by": null,
"created_at": "2026-10-18T09:14:03+00:00"
}
}
}
Verifying the signature
Each endpoint has a signing secret (whsec_…), shown when it is created or rotated.
v1 is the hex HMAC-SHA256 of <t>.<raw body> under that secret.
Compute it over the raw bytes you received — not re-encoded JSON — compare in constant time, and reject a
t more than five minutes from your clock so a captured request cannot be replayed.
// Node.js
const crypto = require('crypto')
function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(',').map(p => p.split('=')))
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
return expected.length === v1.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
}
<?php // PHP
function verify(string $rawBody, string $header, string $secret): bool
{
parse_str(str_replace(',', '&', $header), $p);
if (abs(time() - (int) ($p['t'] ?? 0)) > 300) return false;
return hash_equals(hash_hmac('sha256', ($p['t'] ?? '').'.'.$rawBody, $secret), (string) ($p['v1'] ?? ''));
}
# Python
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
Delivery
- Answer 2xx within 10 seconds. Do the work afterwards, in a queue: a slow answer counts as a failure.
- Retries. A failed delivery is tried six times in all: now, then after 1 minute, 5 minutes, 1 hour, 1 day and 1 day — about two and a half days.
- Duplicates. A retry can arrive after you processed the original.
X-Webhook-Id(and the body’sid) is the same on every attempt and every endpoint: store it and ignore repeats. - Order. Events usually arrive in order, but retries mean they need not. Use
created_atandprevious_statusrather than arrival order, or fetch the resource for its current state. - A failing endpoint is turned off. After five deliveries in a row have each used up every attempt — the first of them at least a day earlier — we stop sending, set
enabledto false withdisabled_reason: failing, and tell the workspace’s owners and admins. Turn it back on when it is fixed, and resend what you missed from its history. - Redirects are not followed. If your endpoint moves, update its URL.
- URLs must be https and resolve to a public address. Private, loopback and link-local addresses are refused, checked again before every delivery.
- History. Each endpoint’s deliveries — payload, response and attempts — are kept for 30 days. Erasing a conversation or message in the inbox erases it from the history too.
- Plans. Webhooks are part of API access. On Starter nothing is sent; endpoints are kept and resume when you upgrade.
Questions or something missing? Start a free trial to try the API, or contact us from inside the app.