Webhooks
Omnio posts signed events to your endpoint as things happen, and retries until you answer.
Endpoints
An owner or admin registers endpoints in Settings, Webhooks, picks the events and copies the signing secret, shown once. The URL must be HTTPS on port 443 and resolve to a public address. An organisation has up to 10 endpoints.
The envelope
Every event arrives as one JSON body. The payload of each type is in data.
{
"id": "evt_8f14e45fceea167a5a36dedd4bea2543",
"type": "message.received",
"version": "2026-10-01",
"org_id": "5f3e2b1c-7a9d-4e21-b8c6-0d4f9a2e7b13",
"occurred_at": "2026-10-05T14:02:11Z",
"data": {
"message_id": "6f0b5a8e-1c52-4d5e-9d57-3f0f2a9d4c11",
"seq": 42,
"conversation_id": "b1c7e0a4-52a3-4a3c-8d0e-6c1b9e5f7a20",
"channel_id": "0e8d3c1f-7a6b-4b52-9e34-2d1f5c8a9b70",
"customer_id": "4829173650",
"type": "text",
"content": { "text": "Where is my transfer?" },
"attachments": [],
"received_at": "2026-10-05T14:02:11Z"
}
}
Delivery is at least once and in no set order. Deduplicate on id and order a conversation's messages by seq.
Verify the signature
| Header | Value |
|---|---|
X-Signature | Hex HMAC-SHA256 of {X-Timestamp}.{raw body}, keyed with the endpoint's secret |
X-Timestamp | Unix seconds when this attempt was sent |
X-Event-Id | The envelope id |
X-Event-Type | The envelope type |
Check the timestamp first and refuse one more than 5 minutes from your clock, which also refuses a replayed request. Then compute the HMAC over the raw bytes, before parsing them, and compare in constant time.
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def is_authentic(secret: str, raw_body: bytes, signature: str, timestamp: str) -> bool:
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
Retries
Answer 2xx within 5 seconds. Any other status, a timeout, a connection error or a redirect fails the attempt. Omnio tries again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours, each time with a fresh timestamp and signature. After the last one the delivery is dead.
An endpoint that fails for 24 hours in a row is disabled. Turn it back on in Settings once it answers again. Do the slow work after you reply, from a queue.
Events
| Type | When |
|---|---|
message.received | A customer sent a message |
message.sent | Meta accepted an outbound message, from the inbox or the API |
message.status_updated | An outbound message moved to sent, delivered, read, failed or rejected |
conversation.created | A customer's first message opened a conversation |
conversation.assigned | The assigned member or team changed |
conversation.status_changed | The status or snooze changed |
note.created | Someone added an internal note |
contact.created | A contact was created |
contact.updated | Fields changed, listed in changed |
contact.deleted | A contact was deleted |
contact.restored | A deleted contact came back |
contact.blocked | A contact was blocked |
contact.unblocked | A contact was unblocked |
contact.lifecycle_changed | A contact moved to another stage |
contact.tag_added | A tag was put on a contact |
contact.tag_removed | A tag was taken off a contact |
import.completed | An import finished or failed |
ping | A test sent from Settings |
Events about a channel go out only while that channel's webhooks are switched on. Customers are named by customer_id, and every timestamp is UTC.
Message status
A status never goes back. When a customer reads a message, the earlier ones still unread each get their own event with inferred set to true.
{
"message_id": "3c5e7a90-8d1b-4e26-a4f7-5b9d0c2e1a68",
"seq": 43,
"conversation_id": "b1c7e0a4-52a3-4a3c-8d0e-6c1b9e5f7a20",
"channel_id": "0e8d3c1f-7a6b-4b52-9e34-2d1f5c8a9b70",
"customer_id": "4829173650",
"message_type": "text",
"status": "read",
"previous_status": "delivered",
"inferred": false,
"read_at": "2026-10-05T14:05:39Z",
"error": null
}