OmnioChat

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/json and Accept: application/json, except for file uploads, which are multipart/form-data.
  • Versioning. The version is in the path. Within v1 we only add — new endpoints, new fields, new events, new reason codes — so ignore fields you do not recognise rather than rejecting them.
  • Rate limit. 120 requests a minute per user. Over it you get 429 with a Retry-After header.

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.

ScopeAllows
*Everything, including scopes added later
conversations:readRead conversations, their messages and notes
conversations:writeAssign, resolve, label and add notes to conversations
messages:writeSend messages and template messages as the business
contacts:readRead contacts and search them
contacts:writeCreate, update and delete contacts
webhooks:readList webhook endpoints and their delivery history
webhooks:writeCreate, update and delete webhook endpoints
analytics:readRead 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 page and per_page (at most 100) and carry meta: 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"
}
StatusreasonMeaning
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.

GET /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 }.

POST /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.

ParameterInType
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 }.

DELETE /devices conversations:read

Stop pushes to a phone

When the user turns notifications off in the app. Signing out does this on its own.

ParameterInType
token* body string The FCM token to remove.

Returns 204.

POST /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.

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

GET /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.

GET /conversations conversations:read

List conversations

Most recent activity first.

ParameterInType
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).

GET /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.

ParameterInType
id* path string Conversation id.

Returns data: a conversation object.

GET /conversations/{id}/messages conversations:read

List messages

Newest first, stable across pages even for messages written in the same second.

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

GET /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.

ParameterInType
messageId* path string Message id.

Returns The file, with its content type.

PATCH /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.

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

POST /conversations/{id}/resolve conversations:write

Resolve

Safe to retry: resolving a resolved conversation succeeds.

ParameterInType
id* path string Conversation id.

Returns data: the conversation.

POST /conversations/{id}/reopen conversations:write

Reopen

Safe to retry.

ParameterInType
id* path string Conversation id.

Returns data: the conversation.

GET /conversations/{id}/notes conversations:read

List internal notes

Agent-only notes, oldest first. Never sent to the customer.

ParameterInType
id* path string Conversation id.

Returns data: note objects.

POST /conversations/{id}/notes conversations:write

Add an internal note

Written as the token’s user.

ParameterInType
id* path string Conversation id.
body* body string Up to 4096 characters.

Returns 201, data: the note.

GET /labels conversations:read

List conversation labels

The workspace’s labels, to turn label_ids into names.

Returns data: [{ id, name, color }].

POST /conversations/{id}/labels conversations:write

Add a label

Adding a label the conversation already has succeeds without duplicating it.

ParameterInType
id* path string Conversation id.
label_id* body integer A label in this workspace.

Returns data: { label_ids }.

DELETE /conversations/{id}/labels/{labelId} conversations:write

Remove a label

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

POST /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.

ParameterInType
id* path string Conversation id.
content* body string Up to 4096 characters.

Returns 201, data: the message.

Refusals window_closed, not_open

POST /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.

ParameterInType
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

GET /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.

ParameterInType
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 }]).

POST /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.

ParameterInType
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).

GET /contacts contacts:read

List and search contacts

Most recently seen first.

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

GET /contacts/{uuid} contacts:read

Get a contact

ParameterInType
uuid* path string Contact id.

Returns data: a contact object.

POST /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.

ParameterInType
phone_e164 body string E.164, e.g. +447700900123. This or email is required.
email 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.

PATCH /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.

ParameterInType
uuid* path string Contact id.
… body Any field from create except tag_ids.

Returns data: the contact.

Refusals duplicate_contact

DELETE /contacts/{uuid} contacts:write

Delete a contact

If the same person messages again they are restored rather than created afresh.

ParameterInType
uuid* path string Contact id.

Returns 204.

GET /contact-tags contacts:read

List contact tags

Returns data: [{ id, name, color }].

POST /contacts/{uuid}/tags contacts:write

Tag a contact

ParameterInType
uuid* path string Contact id.
tag_id* body integer A tag in this workspace.

Returns data: the contact.

DELETE /contacts/{uuid}/tags/{tagId} contacts:write

Untag a contact

ParameterInType
uuid* path string Contact id.
tagId* path integer Tag id.

Returns data: the contact.

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.

GET /webhook-events webhooks:read

List subscribable events

Returns data: [{ event, description }].

GET /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.

POST /webhooks webhooks:write

Create an endpoint

The response includes secret — the only time it is shown. Store it to verify signatures.

ParameterInType
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

GET /webhooks/{uuid} webhooks:read

Get an endpoint

ParameterInType
uuid* path string Endpoint id.

Returns data: the endpoint.

PATCH /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.

ParameterInType
uuid* path string Endpoint id.

Returns data: the endpoint.

Refusals url_refused

DELETE /webhooks/{uuid} webhooks:write

Delete an endpoint

Pending retries are cancelled and the delivery history is removed.

ParameterInType
uuid* path string Endpoint id.

Returns 204.

POST /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.

ParameterInType
uuid* path string Endpoint id.

Returns data: the endpoint plus the new secret.

POST /webhooks/{uuid}/test webhooks:write

Send a test event

Queues a webhook.ping through the real delivery path — signing, retries and all.

ParameterInType
uuid* path string Endpoint id.

Returns 202, data: the delivery.

Refusals endpoint_disabled

GET /webhooks/{uuid}/deliveries webhooks:read

Delivery history

Newest first. Kept for 30 days.

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

POST /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.

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

GET /token-scopes

List scopes

Returns data: [{ scope, description }].

GET /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 }].

POST /tokens

Create a token

The plaintext token is returned once and cannot be recovered.

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

DELETE /tokens/{token}

Revoke a token

Works in any workspace state, so a token you think has leaked can always be revoked.

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

POST /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.

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

POST /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.receivedA customer sent a message
message.status_updatedAn outbound message was sent, delivered, read, or failed
conversation.createdA new conversation started
conversation.assignedA conversation was assigned to a different agent or team
conversation.status_changedA conversation was resolved, reopened, or snoozed
contact.createdA contact was added, by a message, an import, the API or by hand
contact.updatedA contact’s details changed (not just their last-seen time)
campaign.completedA 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’s id) 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_at and previous_status rather 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 enabled to false with disabled_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.