Reference

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 #

EventFires when
message.createdA message is committed. The workhorse event.
message.updatedA message is edited.
message.deletedA message is soft-deleted.
receipt.delivered / receipt.readA receipt is recorded.
reaction.added / reaction.removedA reaction changes.
attachment.ready / attachment.failedUpload verification finished.
member.added / member.removedRoom membership changes.
call.started / call.endedA 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 #

captured live

{
  "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 #

captured live

{
  "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 #

captured live

{
  "event_seq": 5205,
  "id": "4623daf9-3ddf-4801-bf57-c012929b0707"
}
receipt.delivered #

from schema

{
  "principal_id": "bob",
  "message_id": "{message_id}",
  "seq": 12
}
receipt.read #

captured live

{
  "message_id": "4623daf9-3ddf-4801-bf57-c012929b0707"
}
reaction.added #

captured live

{
  "emoji": "🎉",
  "event_seq": 5205,
  "message_id": "4623daf9-3ddf-4801-bf57-c012929b0707",
  "principal_id": "bob"
}
reaction.removed #

from schema

Same envelope; payload mirrors the corresponding socket frame.

attachment.ready #

from schema

{
  "attachment_id": "{attachment_id}",
  "mime": "image/jpeg",
  "byte_size": 528,
  "has_thumbnail": true
}
attachment.failed #

captured live

{
  "attachment_id": "2b575b88-88bb-40d0-b616-57536b782806",
  "original_name": "standup.mp4",
  "reason": "unrecognized or blocked file type",
  "uploader_id": "alice"
}
call.started #

captured live

{
  "call_id": "aee47cf4-5330-4071-9061-7a37610c4497",
  "initiator": "alice"
}
call.ended #

captured live

{
  "call_id": "aee47cf4-5330-4071-9061-7a37610c4497",
  "initiator": "alice"
}
member.added #

from schema

{
  "principal_id": "carol",
  "role": "member"
}
member.removed #

from schema

{
  "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;
}
Always verify, and always check freshness. An unverified webhook endpoint is an unauthenticated write path into your system. Compare in constant time and reject stale timestamps so a captured delivery cannot be replayed against you later.

Retries and failure #

  • Respond 2xx quickly — 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.created and call.started and send FCM/APNs from your app-server instead of storing credentials with us.
  • Ringing. call.started is 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.