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. |
call.started / call.ended | A room's call begins / ends. Both carry the same call_id. |
Delivery shape #
POST /hooks/ollacore
x-ollacore-signature: t=1757500000,v1=9f86d081884c7d65…
{
"id": "evt_7f3a…",
"type": "message.created",
"tenant_id": "acct_…",
"room_id": "0f5c…",
"occurred_at": "2026-09-10T11:04:22Z",
"data": { … }
}
The id is an opaque event id — use it to deduplicate, because at-least-once delivery
means you can legitimately see the same event twice.
A payload per event #
What lands in data for each of the 13 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"
} 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 with exponential backoff over several hours, then given up.
- 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.