Webhooks & events
Server-to-server delivery of the same durable events your clients see on the socket. This is how your backend reacts to activity — moderation, analytics, your own push, CRM sync.
Subscribing #
curl -X POST https://api.ollacore.com/v1/server/webhooks \
-H "Authorization: Bearer ak_live_…" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.example.com/hooks/ollacore",
"event_types": ["message.created","call.started","call.ended"]
}'
# → {
# "id": "whe_2416dc93…", ← this is the webhook id; there is no "webhook_id" field
# "url": "https://your-server.example.com/hooks/ollacore",
# "event_types": ["message.created","call.started","call.ended"],
# "secret": "whsec_…" ← shown once
# } The secret is shown once. Store it — it is what proves a delivery came from us.
Event types #
| Event | Fires when |
|---|---|
message.created | A message is committed. The workhorse event. |
message.updated | A message is edited. |
message.deleted | A message is soft-deleted. |
receipt.delivered / receipt.read | A receipt is recorded. |
reaction.added / reaction.removed | A reaction changes. |
attachment.ready / attachment.failed | Upload verification finished. |
member.added / member.removed | Room membership changes. |
member.updated | A member's role changes — for example, made an owner of a group. |
call.started / call.ended | A room's call begins / ends. Both carry the same call_id. |
rtc.policy.denied | A member's media policy refused something it tried to publish, or closed a connection that did not withdraw a publication. Webhook only — never on the room's sockets. |
rtc.device_conflict | A member reached the device limit its media policy sets. Webhook only. |
Delivery shape #
POST /hooks/ollacore
x-ollacore-event-id: evt_7f3a…
x-ollacore-delivery-id: …
x-ollacore-event-type: message.created
x-ollacore-signature: t=1757500000,v1=9f86d081884c7d65…
{
"version": 1,
"id": "evt_7f3a…",
"type": "message.created",
"tenant_id": "acct_…",
"room_id": "0f5c…",
"occurred_at": "2026-09-10T11:04:22Z",
"data": { … }
}
Delivery is at least once, so you can legitimately see the same event twice. Deduplicate on
x-ollacore-delivery-id (the same on every attempt and replay to one endpoint), or on
the event id — id, also sent as x-ollacore-event-id — across endpoints.
version versions the envelope's seven fields and changes only if one of them is
removed, renamed or changes meaning: refuse a version you do not know rather than guess. Each
event's data grows by adding fields, so ignore fields you do not recognise. A body is
at most 256 KiB; a larger one is never sent (the delivery is marked failed
with the reason), so you may reject anything bigger.
A payload per event #
What lands in data for each of the 16 deliverable types. It is the
same object the chat socket delivers, so anything you write to handle one works for the other.
message.created # {
"body": {
"text": "sent over the socket"
},
"client_message_id": "ws-4e21c7",
"created_at": "2026-01-15T09:30:14Z",
"event_seq": 5205,
"id": "4623daf9-3ddf-4801-bf57-c012929b0707",
"kind": "text",
"room_id": "25964f12-9ae9-4356-80e8-69bc5f749bd1",
"sender_id": "alice"
} message.updated # {
"body": {
"text": "edited over the socket"
},
"client_message_id": "ws-4e21c7",
"created_at": "2026-01-15T09:30:14Z",
"edited_at": "2026-01-15T09:30:16Z",
"event_seq": 5205,
"id": "4623daf9-3ddf-4801-bf57-c012929b0707",
"kind": "text",
"room_id": "25964f12-9ae9-4356-80e8-69bc5f749bd1",
"sender_id": "alice"
} message.deleted # {
"event_seq": 5205,
"id": "4623daf9-3ddf-4801-bf57-c012929b0707"
} receipt.delivered # {
"principal_id": "bob",
"message_id": "{message_id}",
"seq": 12
} receipt.read # {
"message_id": "4623daf9-3ddf-4801-bf57-c012929b0707"
} reaction.added # {
"emoji": "🎉",
"event_seq": 5205,
"message_id": "4623daf9-3ddf-4801-bf57-c012929b0707",
"principal_id": "bob"
} reaction.removed # Same envelope; payload mirrors the corresponding socket frame.
attachment.ready # {
"attachment_id": "{attachment_id}",
"mime": "image/jpeg",
"byte_size": 528,
"has_thumbnail": true
} attachment.failed # {
"attachment_id": "2b575b88-88bb-40d0-b616-57536b782806",
"original_name": "standup.mp4",
"reason": "unrecognized or blocked file type",
"uploader_id": "alice"
} call.started # {
"call_id": "aee47cf4-5330-4071-9061-7a37610c4497",
"initiator": "alice"
} call.ended # {
"call_id": "aee47cf4-5330-4071-9061-7a37610c4497",
"initiator": "alice"
} member.added # {
"principal_id": "carol",
"role": "member"
} member.removed # {
"principal_id": "carol"
} member.updated # Same envelope; payload mirrors the corresponding socket frame.
rtc.policy.denied # Fields: principal_id, client_id, device_id, operation ("publish"), reason (audio_not_allowed, video_not_allowed, audio_track_limit, video_track_limit, publish_forbidden, policy_changed or policy_apply_failed), mids, policy_revision. At most one per member, reason and minute.
rtc.device_conflict # Fields: principal_id, client_id (null for limit_applied), device_id, outcome (admitted, rejected, replaced or limit_applied), on_limit, max_active, active_devices, closed_client_ids, policy_revision.
The delivery log #
Every attempt is recorded and readable — you do not have to guess whether something went out.
GET /v1/server/webhooks/{webhook_id}/deliveries:
[
{
"id": "whd_7d282f27fea842a9ab25a16bc1b2b72a",
"event_id": "evt_16dac5b07cf72653ebcc599c",
"event_type": "call.started",
"status": "delivered",
"attempt": 0,
"last_status_code": 200,
"last_error": null,
"created_at": "2026-01-15T09:30:33Z",
"updated_at": "2026-01-15T09:30:34Z"
},
{
"id": "whd_7eebb7ce833d4b1f8f0d05eb10f7e035",
"event_id": "evt_4656e090af7f80263ee36867",
"event_type": "message.created",
"status": "delivered",
"attempt": 0,
"last_status_code": 200,
"last_error": null,
"created_at": "2026-01-15T09:30:14Z",
"updated_at": "2026-01-15T09:30:15Z"
}
]
A failed delivery can be replayed individually with
POST …/deliveries/{delivery_id}/replay, which is usually what you want after
fixing a bug in your handler rather than re-running a whole day of traffic.
Each delivery keeps the event it sent, message text included, so it can be replayed — for
30 days after it settles (delivered, failed or cancelled), after which it is deleted
and a replay returns 404. Erasing a room, a principal or the tenant deletes the deliveries
of the events erased, and message retention those of the messages it expires. Deleting an endpoint
(DELETE /v1/server/webhooks/{webhook_id}) disables it and keeps its history
until those 30 days pass — see Data lifecycle.
Verifying the signature #
HMAC-SHA256 over {timestamp}.{raw body} with your endpoint secret.
Verify against the raw body — parsing and re-serialising will change the bytes and
break the comparison.
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto
.createHmac("sha256", secret)
.update(parts.t + "." + rawBody)
.digest("hex");
// constant time, and reject anything older than ~5 minutes
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return ok && fresh;
} Retries and failure #
- Respond
2xxquickly — ideally enqueue and return. Slow endpoints are treated as failures. - Non-2xx or timeout is retried: up to 6 attempts, after 30 s, 2 min, 10 min,
30 min and 2 h, then the delivery is marked
failed. Five failed deliveries in a row disable the endpoint. - Every attempt is recorded:
GET /v1/server/webhooks/{id}/deliveries. - Replay any delivery by hand after you fix your side:
POST /v1/server/webhooks/{id}/deliveries/{delivery_id}/replay.
Endpoint rules #
Endpoints must be HTTPS and publicly resolvable. Private, loopback and link-local addresses are refused at creation and re-checked at delivery time, so a webhook cannot be used to probe the inside of the deployment's network. Redirects are not followed.
What people use this for #
- Your own push. Subscribe to
message.createdandcall.startedand send FCM/APNs from your app-server instead of storing credentials with us. - Ringing.
call.startedis the signal that a device which is not in the call should be rung. - Moderation and archiving. Mirror content into your own store or review queue.
- Sync. Keep a CRM, ticketing system or search index current.