API reference

Client API

What your app calls directly. Every path is authorised against the token’s room and principal.

Credential: room-scoped session token · scheme room session token

31 operations · 30 captured against the live API.

Every example below is a real exchange recorded against a throwaway tenant, then redacted — not a shape transcribed from a schema. Ids and timestamps are stable fixtures so the same request always reads the same way. Back to the full index →

Messages #

POST /v1/rooms/{room_id}/messages #

Send

Needs chat:send. Each principal may send 30 messages per 10 seconds, a limit shared with the WebSocket send path. Resending a client_message_id (1–128 characters) returns the original message with duplicate: true and writes, publishes and meters nothing. A new message commits with a durable message.created event. The event goes to the room's sockets and to subscribed webhooks, and members who are offline and not muted get a push. A mention gets through a mute, and in an e2ee room the push carries no sender or preview. Plain rooms accept the kinds text, image, voice, video, story, file and system, with a body of at most 16 KiB and at most 50 active members in body.mentions. E2EE rooms accept only mls.* kinds, with a body of exactly {"ct": <base64>} up to 128 KiB; sending the other room type's kinds returns 409. attachment_ids takes at most 10 attachments from this room, and each must be ready (409 otherwise). reply_to must be a message in this room. With forward_of, the server ignores kind, body and attachment_ids and copies the kind, body and media of a non-deleted message from a room the sender is in, dropping its mentions. An e2ee room cannot receive a forward.

postMessage captured live

Request

curl -X POST https://api.ollacore.com/v1/rooms/25964f12-9ae9-4356-80e8-69bc5f749bd1/messages \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…" \
  -H "Content-Type: application/json" \
  -d '{"client_message_id":"c-9f3b7e21","kind":"text","body":{"text":"Hey — are we still on for 3pm?"}}'
{
  "client_message_id": "c-9f3b7e21",
  "kind": "text",
  "body": {
    "text": "Hey — are we still on for 3pm?"
  }
}
ParameterTypeDescription
room_id path required string (uuid) The room's id. It must be the room the session token was minted for (403 otherwise), and the caller must still be an active member.
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Request fields

FieldTypeDescription
client_message_id required string max length 128 Your idempotency key, 1–128 characters. Resending the same key returns the original message instead of creating a duplicate, so generate it once per message and reuse it on retries.
kind one of: text, image, voice, video, story, file, system, mls.commit, mls.proposal, mls.application, mls.welcome default "text" The message kind. Defaults to text. Plaintext rooms accept the plain kinds; an E2EE room accepts only the mls.* kinds, and a plain room refuses them (409).
body any The message content: any JSON value, at most 16 KiB serialised. The server does not require a shape; clients conventionally send an object such as {"text": …}, which is what previews and search read. For mls.* kinds it must be {"ct": base64}, up to 128 KiB. Required unless forward_of is set. A mentions array of principal ids (at most 50, each an active member of the room, validated on send; 400 otherwise) names members: it is stored and delivered as sent, and a mentioned member is pushed even while they have muted the room. Not on system or mls.* messages.
reply_to string (uuid) Id of a message in the same room this one replies to.
attachment_ids array of string (uuid) max 10 items Up to 10 attachments to attach, each already ready and uploaded to this room.
forward_of string (uuid) Forward a message instead of composing one: the id of a message in any room you are an active member of (this one included). The server copies its kind, body and media into this room — kind, body and attachment_ids in this request are ignored — and the copy carries forward_depth one deeper than the source. Only text, image, voice, video, file and story messages can be forwarded, not a deleted one, and not into an E2EE room (409). A source you cannot see — unknown, another tenant's, in a room you are not in — is one 400. mentions in the source do not travel. Media is copied inside the store as ready attachments of this room, uploaded by you.

Response · 200

{
  "duplicate": false,
  "message": {
    "body": {
      "text": "Hey — are we still on for 3pm?"
    },
    "client_message_id": "c-9f3b7e21",
    "created_at": "2026-01-15T09:30:06Z",
    "event_seq": 5202,
    "id": "e8b186e4-845a-41dc-bb28-102dd8d0cb8d",
    "kind": "text",
    "room_id": "25964f12-9ae9-4356-80e8-69bc5f749bd1",
    "sender_id": "alice"
  }
}
Response fields (28)
FieldTypeDescription
message required Message The message as stored.
message.id required string (uuid) The message's id.
message.event_seq required integer (int64) The message's position: strictly increasing within a room, assigned at commit. Use it for ordering and for catch-up (after_seq). It is not contiguous — one sequence is shared by all rooms — so a jump between two messages does not mean one is missing.
message.room_id required string (uuid) The room the message belongs to.
message.sender_id required string The principal that sent the message. For a client send it comes from the session token, never the body; for a server send (POST /v1/server/rooms/{room_id}/messages) it is the sender_principal_id you named, which must be a registered non-human principal.
message.sender_kind one of: agent, bot, service, system Immutable non-human sender classification captured when a server send creates the message. It survives replay, edit, delete, history, search, realtime events and webhooks. Absent for human-authored messages.
message.client_message_id required string The sender's idempotency key for this message.
message.kind required one of: text, image, voice, video, story, file, system, deleted What the message is. Once a message is soft-deleted its kind reads deleted and its body is null.
message.body required any The message content as sent: any JSON value, at most 16 KiB serialised (conventionally {"text": …}). In an E2EE room it is {"ct": base64} ciphertext, up to 128 KiB. Null once the message is deleted.
message.reply_to string (uuid) The id of the message this one replies to, in the same room. Absent when it is not a reply.
message.forward_depth integer min 1 Present on a forwarded message: how many forwards deep it is (1 for a forward of an original, 2 for a forward of a forward, …). Render "Forwarded" from it, and "Forwarded many times" past a threshold of your choosing. The source message is never named — recipients may not be in the room it came from. Absent on a message composed in its room.
message.created_at required string (date-time) When the message was committed.
message.edited_at string (date-time) When the message was last edited. Absent if never edited.
message.deleted_at string (date-time) When the message was soft-deleted. Absent unless deleted.
message.attachments array of AttachmentRef The files attached to the message. Absent when there are none.
message.attachments[].id required if its parent is sent string (uuid) The attachment's id.
message.attachments[].original_name required if its parent is sent string The file name as uploaded (sanitised).
message.attachments[].mime required if its parent is sent string The attachment's content type.
message.attachments[].byte_size required if its parent is sent integer (int64) Size in bytes.
message.attachments[].status required if its parent is sent string pending until the upload completes, uploaded while it is verified, then ready or failed.
message.attachments[].width integer Image width in pixels, when known.
message.attachments[].height integer Image height in pixels, when known.
message.attachments[].has_thumbnail required if its parent is sent boolean Whether a thumbnail was generated; the download response then carries thumbnail_url.
message.reactions array of ReactionAggregate The message's reactions, aggregated per emoji. Absent when there are none. This is the authoritative state: reaction.added/reaction.removed events are never replayed, so after a reconnect rebuild from here rather than from the events.
message.reactions[].emoji required if its parent is sent string The emoji reacted with.
message.reactions[].count required if its parent is sent integer (int64) How many principals have reacted with it. Always principals.length.
message.reactions[].principals required if its parent is sent array of string Who reacted, by principal id.
duplicate required boolean true when client_message_id was already persisted; the original message is returned
Errors · 400 · 401 · 402 · 403 · 409 · 413 · 429
StatusWhen
400A bad kind, client_message_id, mentions, reply_to or forward_of.
401Missing, expired or invalid credentials
402The tenant has used its monthly message quota (quota_exceeded). Retrying will not help until the quota resets at the start of the month (UTC) or the plan changes.
403Authenticated but not permitted here (code forbidden), or — in a direct turnkey-directory conversation — frozen by a block (code blocked): one of the two people has blocked the other, so neither sends, reacts, types or calls in it until the block is lifted. Branch on code.
409A kind of the other room type (mls.* in a plain room, a plain kind in an e2ee room), an attachment that is not ready, or a forward into an e2ee room.
413Body exceeds MAX_UPLOAD_BYTES or the per-message limit
429Rate limit exceeded
GET /v1/rooms/{room_id}/messages #

History (after_seq / before_seq / limit)

Needs chat:history. limit defaults to 100 and is capped at 500. after_seq returns the messages after that event_seq (exclusive), oldest first. before_seq on its own returns the limit messages just before it, still oldest first, for scrolling back. With both set, the page starts after after_seq and stops before before_seq. Unlike the WebSocket catchup, this endpoint adds no overlap window. Deleted messages stay in the page as tombstones (kind deleted, body null). Each message includes its attachments and reaction aggregates, and latest_seq is the room's newest event_seq. In an e2ee room the bodies are the stored mls.* ciphertext.

listMessages captured live

Request

curl -X GET https://api.ollacore.com/v1/rooms/25964f12-9ae9-4356-80e8-69bc5f749bd1/messages?limit=20 \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…"
ParameterTypeDescription
room_id path required string (uuid) The room's id. It must be the room the session token was minted for (403 otherwise), and the caller must still be an active member.
after_seq query integer (int64) Only messages with event_seq greater than this. After a reconnect, pass the last event_seq you processed. With neither cursor, the page starts at the room's oldest message; with before_seq alone, it pages backwards (see before_seq).
before_seq query integer (int64) Only messages with event_seq less than this. Given without after_seq, it pages backwards: the page is the limit messages just before before_seq, still oldest first. To keep scrolling up, pass the first message's event_seq as the next before_seq. With after_seq as well, the page starts after after_seq and stops before before_seq.
limit query integer default 100 · max 500 Maximum messages, 1–500. Defaults to 100. Counted from the oldest matching message, or from the newest when paging backwards with before_seq alone.
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Response · 200

{
  "messages": [
    {
      "id": "e0b25ea0-1320-4862-af8d-bc28a2547b27",
      "event_seq": 5200,
      "room_id": "25964f12-9ae9-4356-80e8-69bc5f749bd1",
      "sender_id": "support-bot",
      "sender_kind": "agent",
      "client_message_id": "srv-8c1f2a",
      "kind": "text",
      "body": {
        "text": "Your order #7781 has shipped."
      },
      "created_at": "2026-01-15T09:30:04Z"
    },
    {
      "id": "e8b186e4-845a-41dc-bb28-102dd8d0cb8d",
      "event_seq": 5202,
      "room_id": "25964f12-9ae9-4356-80e8-69bc5f749bd1",
      "sender_id": "alice",
      "client_message_id": "c-9f3b7e21",
      "kind": "text",
      "body": {
        "text": "Hey — are we still on for 3pm?"
      },
      "created_at": "2026-01-15T09:30:06Z"
    }
  ],
  "latest_seq": 5202
}
Response fields (28)
FieldTypeDescription
messages required array of Message The messages. From history, oldest first (ascending event_seq); from search, best match first, then newest.
messages[].id required string (uuid) The message's id.
messages[].event_seq required integer (int64) The message's position: strictly increasing within a room, assigned at commit. Use it for ordering and for catch-up (after_seq). It is not contiguous — one sequence is shared by all rooms — so a jump between two messages does not mean one is missing.
messages[].room_id required string (uuid) The room the message belongs to.
messages[].sender_id required string The principal that sent the message. For a client send it comes from the session token, never the body; for a server send (POST /v1/server/rooms/{room_id}/messages) it is the sender_principal_id you named, which must be a registered non-human principal.
messages[].sender_kind one of: agent, bot, service, system Immutable non-human sender classification captured when a server send creates the message. It survives replay, edit, delete, history, search, realtime events and webhooks. Absent for human-authored messages.
messages[].client_message_id required string The sender's idempotency key for this message.
messages[].kind required one of: text, image, voice, video, story, file, system, deleted What the message is. Once a message is soft-deleted its kind reads deleted and its body is null.
messages[].body required any The message content as sent: any JSON value, at most 16 KiB serialised (conventionally {"text": …}). In an E2EE room it is {"ct": base64} ciphertext, up to 128 KiB. Null once the message is deleted.
messages[].reply_to string (uuid) The id of the message this one replies to, in the same room. Absent when it is not a reply.
messages[].forward_depth integer min 1 Present on a forwarded message: how many forwards deep it is (1 for a forward of an original, 2 for a forward of a forward, …). Render "Forwarded" from it, and "Forwarded many times" past a threshold of your choosing. The source message is never named — recipients may not be in the room it came from. Absent on a message composed in its room.
messages[].created_at required string (date-time) When the message was committed.
messages[].edited_at string (date-time) When the message was last edited. Absent if never edited.
messages[].deleted_at string (date-time) When the message was soft-deleted. Absent unless deleted.
messages[].attachments array of AttachmentRef The files attached to the message. Absent when there are none.
messages[].attachments[].id required if its parent is sent string (uuid) The attachment's id.
messages[].attachments[].original_name required if its parent is sent string The file name as uploaded (sanitised).
messages[].attachments[].mime required if its parent is sent string The attachment's content type.
messages[].attachments[].byte_size required if its parent is sent integer (int64) Size in bytes.
messages[].attachments[].status required if its parent is sent string pending until the upload completes, uploaded while it is verified, then ready or failed.
messages[].attachments[].width integer Image width in pixels, when known.
messages[].attachments[].height integer Image height in pixels, when known.
messages[].attachments[].has_thumbnail required if its parent is sent boolean Whether a thumbnail was generated; the download response then carries thumbnail_url.
messages[].reactions array of ReactionAggregate The message's reactions, aggregated per emoji. Absent when there are none. This is the authoritative state: reaction.added/reaction.removed events are never replayed, so after a reconnect rebuild from here rather than from the events.
messages[].reactions[].emoji required if its parent is sent string The emoji reacted with.
messages[].reactions[].count required if its parent is sent integer (int64) How many principals have reacted with it. Always principals.length.
messages[].reactions[].principals required if its parent is sent array of string Who reacted, by principal id.
latest_seq required integer (int64) The room's highest event_seq at query time. Keep it for catch-up after a reconnect.
Errors · 401 · 403 · 429
StatusWhen
401Missing, expired or invalid credentials
403Authenticated, but the token is not scoped to this room or principal
429Rate limit exceeded
PATCH /v1/rooms/{room_id}/messages/{message_id} #

Edit (author only)

Editing a message to the body it already has changes nothing: the same message is returned with 200, edited_at does not move and no second message.updated is published, so a client that resends after losing an ack does not turn one edit into two versions.

editMessage captured live

Request

curl -X PATCH https://api.ollacore.com/v1/rooms/25964f12-9ae9-4356-80e8-69bc5f749bd1/messages/e8b186e4-845a-41dc-bb28-102dd8d0cb8d \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…" \
  -H "Content-Type: application/json" \
  -d '{"body":{"text":"Hey — are we still on for 3:30pm?"}}'
{
  "body": {
    "text": "Hey — are we still on for 3:30pm?"
  }
}
ParameterTypeDescription
room_id path required string (uuid) The room's id. It must be the room the session token was minted for (403 otherwise), and the caller must still be an active member.
message_id path required string (uuid) The message's id, in this room.
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Request fields

FieldTypeDescription
body required any The new content, replacing the old: any JSON value, at most 16 KiB serialised. Only the sender can edit.
if_version string Optional precondition: the version of the message this edit was composed against, which is its edited_at as an RFC 3339 timestamp, or "0" when it has never been edited. The edit applies only while the message is still on that version; otherwise 409 conflict. Without it an edit is last-writer-wins, which is unsafe for a request that may be resent after a lost response.

Response · 200

{
  "message": {
    "body": {
      "text": "Hey — are we still on for 3:30pm?"
    },
    "client_message_id": "c-9f3b7e21",
    "created_at": "2026-01-15T09:30:06Z",
    "edited_at": "2026-01-15T09:30:06Z",
    "event_seq": 5202,
    "id": "e8b186e4-845a-41dc-bb28-102dd8d0cb8d",
    "kind": "text",
    "room_id": "25964f12-9ae9-4356-80e8-69bc5f749bd1",
    "sender_id": "alice"
  }
}
Response fields (27)
FieldTypeDescription
message required Message The message as stored.
message.id required string (uuid) The message's id.
message.event_seq required integer (int64) The message's position: strictly increasing within a room, assigned at commit. Use it for ordering and for catch-up (after_seq). It is not contiguous — one sequence is shared by all rooms — so a jump between two messages does not mean one is missing.
message.room_id required string (uuid) The room the message belongs to.
message.sender_id required string The principal that sent the message. For a client send it comes from the session token, never the body; for a server send (POST /v1/server/rooms/{room_id}/messages) it is the sender_principal_id you named, which must be a registered non-human principal.
message.sender_kind one of: agent, bot, service, system Immutable non-human sender classification captured when a server send creates the message. It survives replay, edit, delete, history, search, realtime events and webhooks. Absent for human-authored messages.
message.client_message_id required string The sender's idempotency key for this message.
message.kind required one of: text, image, voice, video, story, file, system, deleted What the message is. Once a message is soft-deleted its kind reads deleted and its body is null.
message.body required any The message content as sent: any JSON value, at most 16 KiB serialised (conventionally {"text": …}). In an E2EE room it is {"ct": base64} ciphertext, up to 128 KiB. Null once the message is deleted.
message.reply_to string (uuid) The id of the message this one replies to, in the same room. Absent when it is not a reply.
message.forward_depth integer min 1 Present on a forwarded message: how many forwards deep it is (1 for a forward of an original, 2 for a forward of a forward, …). Render "Forwarded" from it, and "Forwarded many times" past a threshold of your choosing. The source message is never named — recipients may not be in the room it came from. Absent on a message composed in its room.
message.created_at required string (date-time) When the message was committed.
message.edited_at string (date-time) When the message was last edited. Absent if never edited.
message.deleted_at string (date-time) When the message was soft-deleted. Absent unless deleted.
message.attachments array of AttachmentRef The files attached to the message. Absent when there are none.
message.attachments[].id required if its parent is sent string (uuid) The attachment's id.
message.attachments[].original_name required if its parent is sent string The file name as uploaded (sanitised).
message.attachments[].mime required if its parent is sent string The attachment's content type.
message.attachments[].byte_size required if its parent is sent integer (int64) Size in bytes.
message.attachments[].status required if its parent is sent string pending until the upload completes, uploaded while it is verified, then ready or failed.
message.attachments[].width integer Image width in pixels, when known.
message.attachments[].height integer Image height in pixels, when known.
message.attachments[].has_thumbnail required if its parent is sent boolean Whether a thumbnail was generated; the download response then carries thumbnail_url.
message.reactions array of ReactionAggregate The message's reactions, aggregated per emoji. Absent when there are none. This is the authoritative state: reaction.added/reaction.removed events are never replayed, so after a reconnect rebuild from here rather than from the events.
message.reactions[].emoji required if its parent is sent string The emoji reacted with.
message.reactions[].count required if its parent is sent integer (int64) How many principals have reacted with it. Always principals.length.
message.reactions[].principals required if its parent is sent array of string Who reacted, by principal id.
Errors · 401 · 403 · 404 · 409 · 429
StatusWhen
401Missing, expired or invalid credentials
403Authenticated, but the token is not scoped to this room or principal
404No such room, message, member or attachment
409Conflicts with current state
429Rate limit exceeded
DELETE /v1/rooms/{room_id}/messages/{message_id} #

Soft delete

The sender may delete their own message, and an owner of the room anyone's — a group admin removing a message — read from the membership when the delete happens, so a demoted owner cannot. Anyone else gets 404, as for an id that does not exist. Idempotent: deleting a message that is already deleted returns the same tombstone with 200 and publishes no second event, so a client that resends after losing an ack is not told the deletion never happened.

deleteMessage captured live

Request

curl -X DELETE https://api.ollacore.com/v1/rooms/25964f12-9ae9-4356-80e8-69bc5f749bd1/messages/883e28db-e669-4fba-9a60-e071d1b79b8d \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…"
ParameterTypeDescription
room_id path required string (uuid) The room's id. It must be the room the session token was minted for (403 otherwise), and the caller must still be an active member.
message_id path required string (uuid) The message's id, in this room.
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Response · 200

{
  "message": {
    "body": null,
    "client_message_id": "c-doomed",
    "created_at": "2026-01-15T09:30:08Z",
    "deleted_at": "2026-01-15T09:30:08Z",
    "event_seq": 5204,
    "id": "883e28db-e669-4fba-9a60-e071d1b79b8d",
    "kind": "deleted",
    "room_id": "25964f12-9ae9-4356-80e8-69bc5f749bd1",
    "sender_id": "alice"
  }
}
Response fields (27)
FieldTypeDescription
message required Message The message as stored.
message.id required string (uuid) The message's id.
message.event_seq required integer (int64) The message's position: strictly increasing within a room, assigned at commit. Use it for ordering and for catch-up (after_seq). It is not contiguous — one sequence is shared by all rooms — so a jump between two messages does not mean one is missing.
message.room_id required string (uuid) The room the message belongs to.
message.sender_id required string The principal that sent the message. For a client send it comes from the session token, never the body; for a server send (POST /v1/server/rooms/{room_id}/messages) it is the sender_principal_id you named, which must be a registered non-human principal.
message.sender_kind one of: agent, bot, service, system Immutable non-human sender classification captured when a server send creates the message. It survives replay, edit, delete, history, search, realtime events and webhooks. Absent for human-authored messages.
message.client_message_id required string The sender's idempotency key for this message.
message.kind required one of: text, image, voice, video, story, file, system, deleted What the message is. Once a message is soft-deleted its kind reads deleted and its body is null.
message.body required any The message content as sent: any JSON value, at most 16 KiB serialised (conventionally {"text": …}). In an E2EE room it is {"ct": base64} ciphertext, up to 128 KiB. Null once the message is deleted.
message.reply_to string (uuid) The id of the message this one replies to, in the same room. Absent when it is not a reply.
message.forward_depth integer min 1 Present on a forwarded message: how many forwards deep it is (1 for a forward of an original, 2 for a forward of a forward, …). Render "Forwarded" from it, and "Forwarded many times" past a threshold of your choosing. The source message is never named — recipients may not be in the room it came from. Absent on a message composed in its room.
message.created_at required string (date-time) When the message was committed.
message.edited_at string (date-time) When the message was last edited. Absent if never edited.
message.deleted_at string (date-time) When the message was soft-deleted. Absent unless deleted.
message.attachments array of AttachmentRef The files attached to the message. Absent when there are none.
message.attachments[].id required if its parent is sent string (uuid) The attachment's id.
message.attachments[].original_name required if its parent is sent string The file name as uploaded (sanitised).
message.attachments[].mime required if its parent is sent string The attachment's content type.
message.attachments[].byte_size required if its parent is sent integer (int64) Size in bytes.
message.attachments[].status required if its parent is sent string pending until the upload completes, uploaded while it is verified, then ready or failed.
message.attachments[].width integer Image width in pixels, when known.
message.attachments[].height integer Image height in pixels, when known.
message.attachments[].has_thumbnail required if its parent is sent boolean Whether a thumbnail was generated; the download response then carries thumbnail_url.
message.reactions array of ReactionAggregate The message's reactions, aggregated per emoji. Absent when there are none. This is the authoritative state: reaction.added/reaction.removed events are never replayed, so after a reconnect rebuild from here rather than from the events.
message.reactions[].emoji required if its parent is sent string The emoji reacted with.
message.reactions[].count required if its parent is sent integer (int64) How many principals have reacted with it. Always principals.length.
message.reactions[].principals required if its parent is sent array of string Who reacted, by principal id.
Errors · 401 · 403 · 404 · 429
StatusWhen
401Missing, expired or invalid credentials
403Authenticated, but the token is not scoped to this room or principal
404No such room, message, member or attachment
429Rate limit exceeded
GET /v1/rooms/{room_id}/messages/search #

Full-text search within the room

Needs chat:history. q must be 1–256 bytes after trimming. The search runs against body.text of messages that are not deleted, using PostgreSQL's simple configuration: there is no stemming and every word must match. Results are ordered by relevance, then newest first. limit defaults to 50 and is capped at 100, and there is no cursor for further pages. Results include attachments but not reactions.

searchMessages captured live

Request

curl -X GET https://api.ollacore.com/v1/rooms/25964f12-9ae9-4356-80e8-69bc5f749bd1/messages/search?q=invoice&limit=20 \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…"
ParameterTypeDescription
room_id path required string (uuid) The room's id. It must be the room the session token was minted for (403 otherwise), and the caller must still be an active member.
q query required string search terms
limit query integer (int64) Maximum results, 1–100. Defaults to 50.
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Response · 200

{
  "messages": [
    {
      "id": "5c6e7377-ca7d-4567-9beb-dafb87d2a4d9",
      "event_seq": 5203,
      "room_id": "25964f12-9ae9-4356-80e8-69bc5f749bd1",
      "sender_id": "bob",
      "client_message_id": "c-searchable",
      "kind": "text",
      "body": {
        "text": "The quarterly invoice is attached."
      },
      "created_at": "2026-01-15T09:30:07Z"
    }
  ],
  "latest_seq": 5203
}
Response fields (28)
FieldTypeDescription
messages required array of Message The messages. From history, oldest first (ascending event_seq); from search, best match first, then newest.
messages[].id required string (uuid) The message's id.
messages[].event_seq required integer (int64) The message's position: strictly increasing within a room, assigned at commit. Use it for ordering and for catch-up (after_seq). It is not contiguous — one sequence is shared by all rooms — so a jump between two messages does not mean one is missing.
messages[].room_id required string (uuid) The room the message belongs to.
messages[].sender_id required string The principal that sent the message. For a client send it comes from the session token, never the body; for a server send (POST /v1/server/rooms/{room_id}/messages) it is the sender_principal_id you named, which must be a registered non-human principal.
messages[].sender_kind one of: agent, bot, service, system Immutable non-human sender classification captured when a server send creates the message. It survives replay, edit, delete, history, search, realtime events and webhooks. Absent for human-authored messages.
messages[].client_message_id required string The sender's idempotency key for this message.
messages[].kind required one of: text, image, voice, video, story, file, system, deleted What the message is. Once a message is soft-deleted its kind reads deleted and its body is null.
messages[].body required any The message content as sent: any JSON value, at most 16 KiB serialised (conventionally {"text": …}). In an E2EE room it is {"ct": base64} ciphertext, up to 128 KiB. Null once the message is deleted.
messages[].reply_to string (uuid) The id of the message this one replies to, in the same room. Absent when it is not a reply.
messages[].forward_depth integer min 1 Present on a forwarded message: how many forwards deep it is (1 for a forward of an original, 2 for a forward of a forward, …). Render "Forwarded" from it, and "Forwarded many times" past a threshold of your choosing. The source message is never named — recipients may not be in the room it came from. Absent on a message composed in its room.
messages[].created_at required string (date-time) When the message was committed.
messages[].edited_at string (date-time) When the message was last edited. Absent if never edited.
messages[].deleted_at string (date-time) When the message was soft-deleted. Absent unless deleted.
messages[].attachments array of AttachmentRef The files attached to the message. Absent when there are none.
messages[].attachments[].id required if its parent is sent string (uuid) The attachment's id.
messages[].attachments[].original_name required if its parent is sent string The file name as uploaded (sanitised).
messages[].attachments[].mime required if its parent is sent string The attachment's content type.
messages[].attachments[].byte_size required if its parent is sent integer (int64) Size in bytes.
messages[].attachments[].status required if its parent is sent string pending until the upload completes, uploaded while it is verified, then ready or failed.
messages[].attachments[].width integer Image width in pixels, when known.
messages[].attachments[].height integer Image height in pixels, when known.
messages[].attachments[].has_thumbnail required if its parent is sent boolean Whether a thumbnail was generated; the download response then carries thumbnail_url.
messages[].reactions array of ReactionAggregate The message's reactions, aggregated per emoji. Absent when there are none. This is the authoritative state: reaction.added/reaction.removed events are never replayed, so after a reconnect rebuild from here rather than from the events.
messages[].reactions[].emoji required if its parent is sent string The emoji reacted with.
messages[].reactions[].count required if its parent is sent integer (int64) How many principals have reacted with it. Always principals.length.
messages[].reactions[].principals required if its parent is sent array of string Who reacted, by principal id.
latest_seq required integer (int64) The room's highest event_seq at query time. Keep it for catch-up after a reconnect.
Errors · 400 · 401 · 403 · 409 · 429
StatusWhen
400q is empty or longer than 256 bytes after trimming.
401Missing, expired or invalid credentials
403Authenticated, but the token is not scoped to this room or principal
409room is e2ee; the server cannot search ciphertext
429Rate limit exceeded
POST /v1/rooms/{room_id}/messages/{message_id}/reactions #

Add a reaction

Needs chat:send. emoji is trimmed and must be 1–8 characters and at most 32 bytes. A message holds at most 20 distinct emoji; once it has 20, every add returns 400. When the set changes, a durable reaction.added event { message_id, event_seq, principal_id, emoji } goes to the room's sockets and to subscribed webhooks; a repeat publishes nothing. In an e2ee room, clients send reactions as encrypted mls.application messages instead. In a direct conversation frozen by a block, the call returns 403 blocked.

addReaction captured live

Request

curl -X POST https://api.ollacore.com/v1/rooms/25964f12-9ae9-4356-80e8-69bc5f749bd1/messages/e8b186e4-845a-41dc-bb28-102dd8d0cb8d/reactions \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…" \
  -H "Content-Type: application/json" \
  -d '{"emoji":"👍"}'
{
  "emoji": "👍"
}
ParameterTypeDescription
room_id path required string (uuid) The room's id. It must be the room the session token was minted for (403 otherwise), and the caller must still be an active member.
message_id path required string (uuid) The message's id, in this room.
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Request fields

FieldTypeDescription
emoji required string max length 32 The reaction: 1–8 characters and at most 32 bytes, usually a single emoji. A message holds a bounded number of distinct reactions.

Response · 200

{
  "changed": true
}
Response fields (1)
FieldTypeDescription
changed required boolean false when the call was a no-op, which makes retries safe
Errors · 401 · 403 · 404 · 409 · 429
StatusWhen
401Missing, expired or invalid credentials
403Authenticated but not permitted here (code forbidden), or — in a direct turnkey-directory conversation — frozen by a block (code blocked): one of the two people has blocked the other, so neither sends, reacts, types or calls in it until the block is lifted. Branch on code.
404No such room, message, member or attachment
409room is e2ee
429Rate limit exceeded
DELETE /v1/rooms/{room_id}/messages/{message_id}/reactions #

Remove a reaction

Needs chat:send. It removes only the caller's own reaction with that emoji, which follows the same 1–8 character rule as adding. When a reaction was removed, a durable reaction.removed event goes to the room's sockets and to subscribed webhooks. Removing a reaction that is not there returns changed: false and publishes nothing. Like adding, it returns 409 in an e2ee room and 403 blocked in a direct conversation frozen by a block.

removeReaction captured live

Request

curl -X DELETE https://api.ollacore.com/v1/rooms/25964f12-9ae9-4356-80e8-69bc5f749bd1/messages/e8b186e4-845a-41dc-bb28-102dd8d0cb8d/reactions \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…" \
  -H "Content-Type: application/json" \
  -d '{"emoji":"👍"}'
{
  "emoji": "👍"
}
ParameterTypeDescription
room_id path required string (uuid) The room's id. It must be the room the session token was minted for (403 otherwise), and the caller must still be an active member.
message_id path required string (uuid) The message's id, in this room.
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Request fields

FieldTypeDescription
emoji required string max length 32 The reaction: 1–8 characters and at most 32 bytes, usually a single emoji. A message holds a bounded number of distinct reactions.

Response · 200

{
  "changed": true
}
Response fields (1)
FieldTypeDescription
changed required boolean false when the call was a no-op, which makes retries safe
Errors · 401 · 403 · 404 · 409 · 429
StatusWhen
401Missing, expired or invalid credentials
403Authenticated but not permitted here (code forbidden), or — in a direct turnkey-directory conversation — frozen by a block (code blocked): one of the two people has blocked the other, so neither sends, reacts, types or calls in it until the block is lifted. Branch on code.
404No such room, message, member or attachment
409The room is e2ee.
429Rate limit exceeded

Receipts & presence #

POST /v1/rooms/{room_id}/messages/{message_id}/delivered #

Delivery receipt

Needs chat:send, so a viewer cannot post receipts. A principal's first delivery receipt for a message is recorded and publishes a durable receipt.delivered event { message_id, event_seq, principal_id } to the room's sockets and to subscribed webhooks. Posting it again returns advanced: false and publishes nothing. A delivery receipt does not change last_read_seq or the unread count.

receiptDelivered captured live

Request

curl -X POST https://api.ollacore.com/v1/rooms/25964f12-9ae9-4356-80e8-69bc5f749bd1/messages/e8b186e4-845a-41dc-bb28-102dd8d0cb8d/delivered \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…"
ParameterTypeDescription
room_id path required string (uuid) The room's id. It must be the room the session token was minted for (403 otherwise), and the caller must still be an active member.
message_id path required string (uuid) The message's id, in this room.
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Response · 200

{
  "advanced": true
}
Response fields (1)
FieldTypeDescription
advanced required boolean false when the cursor already covered this message, which makes retries safe
Errors · 401 · 403 · 404 · 429
StatusWhen
401Missing, expired or invalid credentials
403Authenticated, but the token is not scoped to this room or principal
404No such room, message, member or attachment
429Rate limit exceeded
POST /v1/rooms/{room_id}/messages/{message_id}/read #

Read receipt (advances cursor)

Needs chat:send, so a viewer cannot post receipts. A principal's first read receipt for a message is recorded and publishes a durable receipt.read event { message_id, event_seq, principal_id } to the room's sockets and to subscribed webhooks. It also raises the member's last_read_seq to that message's event_seq (never lowering it); GET /v1/rooms/{room_id}/unread counts from that value. Posting it again returns advanced: false and publishes nothing. advanced is tracked per message, so a first receipt for an older message returns true without moving last_read_seq.

receiptRead captured live

Request

curl -X POST https://api.ollacore.com/v1/rooms/25964f12-9ae9-4356-80e8-69bc5f749bd1/messages/e8b186e4-845a-41dc-bb28-102dd8d0cb8d/read \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…"
ParameterTypeDescription
room_id path required string (uuid) The room's id. It must be the room the session token was minted for (403 otherwise), and the caller must still be an active member.
message_id path required string (uuid) The message's id, in this room.
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Response · 200

{
  "advanced": true
}
Response fields (1)
FieldTypeDescription
advanced required boolean false when the cursor already covered this message, which makes retries safe
Errors · 401 · 403 · 404 · 429
StatusWhen
401Missing, expired or invalid credentials
403Authenticated, but the token is not scoped to this room or principal
404No such room, message, member or attachment
429Rate limit exceeded
GET /v1/rooms/{room_id}/unread #

Unread count and cursors

Needs only active membership. unread_count counts messages after last_read_seq that someone else sent and that are not deleted. last_read_seq moves only forward, and only through read receipts. latest_seq is the room's newest event_seq. E2EE rooms work the same way.

unread captured live

Request

curl -X GET https://api.ollacore.com/v1/rooms/25964f12-9ae9-4356-80e8-69bc5f749bd1/unread \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…"
ParameterTypeDescription
room_id path required string (uuid) The room's id. It must be the room the session token was minted for (403 otherwise), and the caller must still be an active member.
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Response · 200

{
  "unread_count": 0,
  "last_read_seq": 5202,
  "latest_seq": 5202
}
Response fields (3)
FieldTypeDescription
unread_count required integer (int64) messages after last_read_seq not sent by the caller
last_read_seq required integer (int64) read cursor; advances monotonically and syncs across the devices of a principal
latest_seq required integer (int64) The room's highest event_seq at the time of the request.
Errors · 401 · 403 · 429
StatusWhen
401Missing, expired or invalid credentials
403Authenticated, but the token is not scoped to this room or principal
429Rate limit exceeded
GET /v1/rooms/{room_id}/participants #

Room participants

Needs only active membership. Lists the room's current members (anyone who has left is excluded) with their role and an online flag. online is true while the member keeps a chat WebSocket open to this room; the presence heartbeat expires after 30 seconds. If presence cannot be read, every member is reported offline and the request still succeeds.

participants captured live

Request

curl -X GET https://api.ollacore.com/v1/rooms/25964f12-9ae9-4356-80e8-69bc5f749bd1/participants \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…"
ParameterTypeDescription
room_id path required string (uuid) The room's id. It must be the room the session token was minted for (403 otherwise), and the caller must still be an active member.
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Response · 200

{
  "participants": [
    {
      "principal_id": "alice",
      "role": "owner",
      "online": false
    },
    {
      "principal_id": "bob",
      "role": "member",
      "online": false
    },
    {
      "principal_id": "carol",
      "role": "member",
      "online": false
    },
    {
      "principal_id": "support-bot",
      "role": "member",
      "online": false
    }
  ]
}
Response fields (4)
FieldTypeDescription
participants required array of Participant Every active member of the room, with chat presence. Not who is in a call.
participants[].principal_id required string The member's principal id.
participants[].role required string The member's role: owner, member or viewer.
participants[].online required boolean live presence at query time, from Valkey; absent presence reads as false rather than failing the call
Errors · 401 · 403 · 429
StatusWhen
401Missing, expired or invalid credentials
403Authenticated, but the token is not scoped to this room or principal
429Rate limit exceeded

Attachments #

POST /v1/rooms/{room_id}/attachments/init #

Begin an upload

Needs attachment:upload. declared_mime must be a permitted type; an e2ee room accepts only application/octet-stream ciphertext and refuses poster_of. original_name must be 1–255 bytes with no /, \, NUL or ... byte_size must be between 1 and the deployment's MAX_UPLOAD_BYTES (100 MiB by default); a larger size returns 400. The monthly byte quota is checked against the declared size. poster_of marks an image as the poster of a video attachment in the same room. The response is a presigned PUT URL whose lifetime grows with the size, from 5 minutes to 2 hours, plus the headers to send with it. An upload that has not been completed and verified within 2 hours is deleted. Files larger than 5 MiB can use init-multipart instead.

initAttachment captured live

Request

curl -X POST https://api.ollacore.com/v1/rooms/25964f12-9ae9-4356-80e8-69bc5f749bd1/attachments/init \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…" \
  -H "Content-Type: application/json" \
  -d '{"original_name":"invoice-q3.jpg","declared_mime":"image/jpeg","byte_size":160}'
{
  "original_name": "invoice-q3.jpg",
  "declared_mime": "image/jpeg",
  "byte_size": 160
}
ParameterTypeDescription
room_id path required string (uuid) The room's id. It must be the room the session token was minted for (403 otherwise), and the caller must still be an active member.
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Request fields

FieldTypeDescription
original_name required string max length 255 The file name, 1–255 bytes after trimming. Names containing /, \, a NUL byte or .. are refused.
declared_mime required one of: image/jpeg, image/png, image/webp, image/gif, image/heic, image/heif, audio/ogg, audio/mpeg, audio/mp4, audio/webm, video/mp4, video/webm, video/quicktime, application/pdf, text/plain, application/zip, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.openxmlformats-officedocument.presentationml.presentation The file's type. It must be on the allowlist, and the server checks the uploaded bytes match it. JPEG, PNG, WebP and GIF are thumbnailed; HEIC/HEIF (an iPhone photo) is stored and served as sent, without a thumbnail. Word, Excel and PowerPoint (Office Open XML) and ZIP are served for download only; legacy .doc/.xls/.ppt are refused. In an E2EE room only application/octet-stream (ciphertext) is accepted.
byte_size required integer (int64) min 1 Exact size in bytes, from 1 up to the deployment's upload limit (100 MiB by default). Counts against the tenant's monthly byte quota.
sha256 string Optional SHA-256 of the file, hex. When given, the upload is rejected if the stored bytes do not match it.
poster_of string (uuid) Declare this upload as the poster frame of an existing video attachment in the same room. The upload must be an image and the referenced attachment a video, else 400. The video's download response then carries poster_url.

Response · 200

{
  "attachment_id": "ee86f695-3f58-48e2-9ca8-66ee9651ee3e",
  "upload_url": "https://files.example.com/objects/…?X-Amz-Signature=…",
  "upload_expires_at": "2026-01-15T09:35:10Z",
  "required_headers": [
    [
      "content-type",
      "application/octet-stream"
    ]
  ]
}
Response fields (4)
FieldTypeDescription
attachment_id required string (uuid) The attachment's id. Complete the upload with it, then attach it to a message.
upload_url required string (uri) Presigned PUT URL, 5-minute expiry; upload directly, bytes never pass through the API
upload_expires_at required string (date-time) When upload_url stops accepting the PUT. The window scales with the file size, from 5 minutes up to 2 hours.
required_headers required array of array of any Headers the PUT to upload_url must carry, as [name, value] pairs. The signature covers them, so send each one exactly.
Errors · 400 · 401 · 402 · 403 · 413 · 415 · 429
StatusWhen
400byte_size outside 1 to the deployment's upload limit, or a bad original_name or poster_of.
401Missing, expired or invalid credentials
402This upload would take the tenant past its monthly byte quota (quota_exceeded). Retrying will not help until the quota resets at the start of the month (UTC) or the plan changes.
403Authenticated, but the token is not scoped to this room or principal
413The request body is over 512 KiB.
415Blocked or unknown type
429Rate limit exceeded
POST /v1/rooms/{room_id}/attachments/{attachment_id}/complete #

Finish and verify

Only the uploader may complete an upload (403 otherwise). The server checks that the object exists at exactly the declared byte_size (409 if it is missing or a different size). It then marks the attachment uploaded and queues a single verification job. That job re-checks the size, checks sha256 if one was declared and requires the leading bytes to match declared_mime; images also get a thumbnail. The result arrives as a durable attachment.ready or attachment.failed event; attachment.failed includes a reason, and the failed object is deleted. Only a ready attachment can be sent in a message or downloaded. Calling complete again after the attachment is ready returns 200 with status ready; calling it while verification is running, or after it failed, returns 409. In an e2ee room, ciphertext is checked only by size and the optional sha256.

completeAttachment captured live

Request

curl -X POST https://api.ollacore.com/v1/rooms/25964f12-9ae9-4356-80e8-69bc5f749bd1/attachments/ee86f695-3f58-48e2-9ca8-66ee9651ee3e/complete \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…" \
  -H "Content-Type: application/json" \
  -d '{}'
{}
ParameterTypeDescription
room_id path required string (uuid) The room's id. It must be the room the session token was minted for (403 otherwise), and the caller must still be an active member.
attachment_id path required string (uuid) The attachment's id, as returned when the upload began.
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Response · 200

{
  "attachment_id": "ee86f695-3f58-48e2-9ca8-66ee9651ee3e",
  "status": "uploaded"
}
Response fields (2)
FieldTypeDescription
attachment_id required string (uuid) The attachment's id.
status required one of: uploaded, ready uploaded — accepted and queued for verification (the normal answer); ready — this attachment was already verified, so completing it again is a no-op. Any other state answers 409.
Errors · 401 · 403 · 404 · 409 · 429
StatusWhen
401Missing, expired or invalid credentials
403Authenticated, but the token is not scoped to this room or principal
404No such room, message, member or attachment
409The object is not uploaded yet or its size differs from byte_size, or the attachment is already verifying or failed.
429Rate limit exceeded
POST /v1/rooms/{room_id}/attachments/init-multipart #

Begin a multipart upload

Needs attachment:upload, and applies the same declared_mime, original_name, e2ee and monthly byte quota rules as init. byte_size must be over 5 MiB and at most the deployment's MAX_UPLOAD_BYTES (400 otherwise); smaller files use init. part_size defaults to 16 MiB and is clamped to 5–64 MiB. An upload may have at most 512 parts; going over returns 400 asking for a larger part_size. The file is split into part_size chunks, one per URL in order, with a shorter last chunk. Keep the ETag that each part's PUT returns, because complete-multipart needs it. All part URLs share one expires_at, 5 minutes to 2 hours depending on size. An upload not completed within 6 hours is aborted and deleted.

initMultipart captured live

Request

curl -X POST https://api.ollacore.com/v1/rooms/25964f12-9ae9-4356-80e8-69bc5f749bd1/attachments/init-multipart \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…" \
  -H "Content-Type: application/json" \
  -d '{"original_name":"standup.mp4","declared_mime":"video/mp4","byte_size":5243904,"part_size":5242880}'
{
  "original_name": "standup.mp4",
  "declared_mime": "video/mp4",
  "byte_size": 5243904,
  "part_size": 5242880
}
ParameterTypeDescription
room_id path required string (uuid) The room's id. It must be the room the session token was minted for (403 otherwise), and the caller must still be an active member.
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Request fields

FieldTypeDescription
original_name required string The file name, 1–255 bytes after trimming. Names containing /, \, a NUL byte or .. are refused.
declared_mime required string verified against magic bytes after upload; a mismatch fails the attachment
byte_size required integer (int64) Exact size in bytes. Multipart is for files larger than 5 MiB, up to the deployment's upload limit (100 MiB by default); smaller files use the single-part upload.
part_size integer (int64) optional; server chooses a default when omitted

Response · 200

{
  "attachment_id": "2b575b88-88bb-40d0-b616-57536b782806",
  "upload_id": "ZTY5ODdmMjMtMGI1OS00Y2QxLTk5MzYtNzllZjk2YjdiZDY5LmZkZGEzZGE4LTJmYjUtNDJjZS1iY2E2LTk4ZDhiZjM3NGJkOXgxNzkyMzI0Njc1MTk1NzY5NDk1",
  "part_size": 5242880,
  "part_urls": [
    "https://files.example.com/objects/…?X-Amz-Signature=…",
    "https://files.example.com/objects/…?X-Amz-Signature=…"
  ],
  "expires_at": "2026-01-15T09:35:12Z"
}
Response fields (5)
FieldTypeDescription
attachment_id required string (uuid) The attachment's id. Use it to complete the upload and to attach the file to a message.
upload_id required string The object store's id for this multipart upload. Opaque; the complete call does not need it.
part_size required integer (int64) Bytes per part: 16 MiB unless you asked for another size, clamped to 5–64 MiB. Split the file at this size; only the last part may be smaller.
part_urls required array of string (uri) one presigned PUT per part, in order
expires_at required string (date-time) When the part URLs stop accepting uploads.
Errors · 400 · 401 · 402 · 403 · 413 · 415 · 429
StatusWhen
400byte_size not over 5 MiB or over the deployment's upload limit, more than 512 parts at this part_size, or a bad original_name.
401Missing, expired or invalid credentials
402This upload would take the tenant past its monthly byte quota (quota_exceeded). Retrying will not help until the quota resets at the start of the month (UTC) or the plan changes.
403Authenticated, but the token is not scoped to this room or principal
413The request body is over 512 KiB.
415declared type not permitted
429Rate limit exceeded
POST /v1/rooms/{room_id}/attachments/{attachment_id}/complete-multipart #

Finish multipart

Only the uploader may complete an upload, and only while the attachment is still pending. Unlike complete, a retry after a successful completion returns 409. parts lists 1–512 { part_number, etag } pairs. An empty or oversized list returns 400; a list the object store rejects, such as one with a missing part or a wrong ETag, returns 409. The reassembled object must match the declared byte_size exactly (409 otherwise). The attachment then becomes uploaded and goes through the same verification as a single-part upload, ending in a durable attachment.ready or attachment.failed event.

completeMultipart captured live

Request

curl -X POST https://api.ollacore.com/v1/rooms/25964f12-9ae9-4356-80e8-69bc5f749bd1/attachments/2b575b88-88bb-40d0-b616-57536b782806/complete-multipart \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…" \
  -H "Content-Type: application/json" \
  -d '{"parts":[{"part_number":1,"etag":"b8fc857a25e7958868c2f003d5e0952d"},{"part_number":2,"etag":"e9f8000caffbce369d7fee9c07d43509"}]}'
{
  "parts": [
    {
      "part_number": 1,
      "etag": "b8fc857a25e7958868c2f003d5e0952d"
    },
    {
      "part_number": 2,
      "etag": "e9f8000caffbce369d7fee9c07d43509"
    }
  ]
}
ParameterTypeDescription
room_id path required string (uuid) The room's id. It must be the room the session token was minted for (403 otherwise), and the caller must still be an active member.
attachment_id path required string (uuid) The attachment's id, as returned when the upload began.
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Request fields

FieldTypeDescription
parts required array of MultipartPart Every part, with the ETag header the object store returned for its PUT, in part-number order.
parts[].part_number required integer 1-based
parts[].etag required string ETag returned by the presigned PUT for this part

Response · 200

{
  "attachment_id": "2b575b88-88bb-40d0-b616-57536b782806",
  "status": "uploaded"
}
Response fields (2)
FieldTypeDescription
attachment_id required string (uuid) The attachment's id.
status required one of: uploaded Always uploaded — accepted and queued for verification. Unlike the single-part complete there is no idempotent no-op: completing again once the upload has left pending answers 409.
Errors · 400 · 401 · 403 · 404 · 409 · 429
StatusWhen
400An empty parts list, or one over 512.
401Missing, expired or invalid credentials
403Authenticated, but the token is not scoped to this room or principal
404No such room, message, member or attachment
409The attachment is not a pending multipart upload (so a retry after success is a 409), the store rejected the part list, or the assembled size differs from byte_size.
429Rate limit exceeded
GET /v1/rooms/{room_id}/attachments/{attachment_id}/download #

Presigned download

Needs attachment:download. Returns a presigned GET download_url valid for 5 minutes, along with the verified mime, original_name and a disposition (inline or attachment; e2ee ciphertext is always attachment). Images also get a thumbnail_url, and videos get a poster_url when a ready poster exists; both are presigned for the same 5 minutes. Request the attachment again for fresh URLs instead of caching them. An attachment that is not ready, whether still verifying or failed, returns 409.

downloadAttachment captured live

Request

curl -X GET https://api.ollacore.com/v1/rooms/25964f12-9ae9-4356-80e8-69bc5f749bd1/attachments/ee86f695-3f58-48e2-9ca8-66ee9651ee3e/download \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…"
ParameterTypeDescription
room_id path required string (uuid) The room's id. It must be the room the session token was minted for (403 otherwise), and the caller must still be an active member.
attachment_id path required string (uuid) The attachment's id, as returned when the upload began.
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Response · 200

{
  "download_url": "https://files.example.com/objects/…?X-Amz-Signature=…",
  "expires_at": "2026-01-15T09:35:12Z",
  "mime": "image/jpeg",
  "original_name": "invoice-q3.jpg",
  "thumbnail_url": "https://files.example.com/objects/…?X-Amz-Signature=…",
  "disposition": "inline"
}
Response fields (7)
FieldTypeDescription
download_url required string (uri) A presigned GET URL for the file.
expires_at required string (date-time) When download_url (and thumbnail_url) stop working. Ask again for a fresh one.
mime required string The file's content type.
original_name required string The file name as uploaded (sanitised).
thumbnail_url string (uri) Presigned URL of the generated thumbnail. Absent when none exists.
poster_url string (uri) Presigned URL of the video's poster frame, when one was uploaded with poster_of. Absent otherwise.
disposition required one of: inline, attachment How the URL serves the file: inline for images, audio and video; attachment (a download) for everything else, such as PDFs, text and zip files.
Errors · 401 · 403 · 404 · 409 · 429
StatusWhen
401Missing, expired or invalid credentials
403Authenticated, but the token is not scoped to this room or principal
404No such room, message, member or attachment
409The attachment is not ready: still verifying, or failed.
429Rate limit exceeded

Push #

POST /v1/push/subscriptions #

Register a Web Push subscription

The token's principal must still be an active member of the token's room. endpoint must be an https URL of at most 1024 characters whose host is not a private, loopback or link-local IP literal (400 otherwise). Host names are resolved and checked again each time a push is sent. The subscription belongs to the principal, not to one room or device. Subscribing the same endpoint again replaces its keys and clears any earlier delivery failure. The subscription receives pushes for new messages in the principal's rooms when they have no chat socket open to that room (at most one per room every 30 seconds), and for call rings. Web Push only works when the deployment has a VAPID key configured (see GET /v1/push/vapid-public-key).

pushSubscribe captured live

Request

curl -X POST https://api.ollacore.com/v1/push/subscriptions \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…" \
  -H "Content-Type: application/json" \
  -d '{"endpoint":"https://fcm.googleapis.com/fcm/send/EXAMPLE","keys":{"p256dh":"<redacted>","auth":"c2VjcmV0MTIzNDU2Nzg"},"user_agent":"Mozilla/5.0 (Android 14; Pixel 8)"}'
{
  "endpoint": "https://fcm.googleapis.com/fcm/send/EXAMPLE",
  "keys": {
    "p256dh": "<redacted>",
    "auth": "c2VjcmV0MTIzNDU2Nzg"
  },
  "user_agent": "Mozilla/5.0 (Android 14; Pixel 8)"
}
ParameterTypeDescription
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Request fields

FieldTypeDescription
endpoint required string (uri) Push service endpoint from PushSubscription.toJSON(). Must be https with a public host: cleartext, loopback, private and link-local IP literals are rejected with 400. A hostname is resolved at each delivery; if it resolves to such an address the server does not connect and drops the subscription.
keys required PushKeys The encryption keys from PushSubscription.toJSON().
keys.p256dh required string The subscription's public encryption key, exactly as PushSubscription.toJSON() returns it (base64url).
keys.auth required string The subscription's authentication secret, exactly as PushSubscription.toJSON() returns it (base64url).
user_agent string Optional free-text label for the device, stored with the subscription.

Response · 200

{
  "subscribed": true
}
Response fields (1)
FieldTypeDescription
subscribed required boolean Always true on success.
Errors · 400 · 401 · 403 · 429
StatusWhen
400endpoint is not https, is over 1024 characters, or names a private, loopback or link-local address.
401Missing, expired or invalid credentials
403The principal is no longer an active member of the token's room.
429Rate limit exceeded
DELETE /v1/push/subscriptions #

Unregister one

Deletes only the caller's own subscription for that endpoint and returns removed. Deleting a subscription that does not exist returns removed: false, so the call is safe to retry. It checks the token but not membership, so a principal removed from the room can still delete its subscription.

pushUnsubscribe captured live

Request

curl -X DELETE https://api.ollacore.com/v1/push/subscriptions \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…" \
  -H "Content-Type: application/json" \
  -d '{"endpoint":"https://fcm.googleapis.com/fcm/send/EXAMPLE"}'
{
  "endpoint": "https://fcm.googleapis.com/fcm/send/EXAMPLE"
}
ParameterTypeDescription
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Request fields

FieldTypeDescription
endpoint required string (uri) The subscription's endpoint URL. Only your own subscription is removed; the call works even after you leave the room, while your token is still valid.

Response · 200

{
  "removed": true
}
Response fields (1)
FieldTypeDescription
removed required boolean True if a subscription owned by the caller matched the endpoint and was deleted; false if there was nothing to delete.
Errors · 401 · 429
StatusWhen
401Missing, expired or invalid credentials
429Rate limit exceeded
GET /v1/push/vapid-public-key #

VAPID public key

Needs no authentication. Pass vapid_public_key as applicationServerKey to PushManager.subscribe(), then register the resulting subscription with POST /v1/push/subscriptions. A 404 means Web Push is not enabled on this deployment.

vapidPublicKey captured live

Request

curl -X GET https://api.ollacore.com/v1/push/vapid-public-key \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…"
ParameterTypeDescription
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Response · 200

{
  "vapid_public_key": "BOCW_h9I_7rKsBL9E6EXl2eENH5vPkbuYPcIR9AVhUDwFJipZ4vC5RMAUqiKWSlVEhK3T2Zyv0DnJGXC1i0cNoE"
}
Response fields (1)
FieldTypeDescription
vapid_public_key required string The server's VAPID public key. Pass it as applicationServerKey to pushManager.subscribe.
Errors · 404
StatusWhen
404push not configured on this deployment

End-to-end encryption #

POST /v1/e2ee/keys/upload #

Publish device + one-time keys

Limited to 30 calls per principal per 10 seconds, and the principal must still be an active member of the token's room. device_keys replaces the calling device's identity keys. The first upload for a new device bumps the principal's device-list version, so co-members see the principal in device_list_changes. Each call takes up to 100 one_time_keys and 8 fallback_keys, and each key blob may be at most 64 KiB serialized (400 otherwise). A one-time key whose id is already stored is ignored, and a fallback key replaces the device's previous fallback key for that algorithm.

keysUpload captured live

Request

curl -X POST https://api.ollacore.com/v1/e2ee/keys/upload \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…" \
  -H "Content-Type: application/json" \
  -d '{"device_keys":{"user_id":"alice","device_id":"dev-alice","algorithms":["m.olm.v1.curve25519-aes-sha2","m.megolm.v1.aes-sha2"],"keys":{"curve25519:dev-alice":"JGDn8Xz3…","ed25519:dev-alice":"lEuiSJHm…"}},"one_time_keys":{"signed_curve25519:AAAAAQ":{"key":"zKbLg+NrIjpn…"},"signed_curve25519:AAAAAg":{"key":"mYWt2yTv8p…"},"signed_curve25519:AAAAAw":{"key":"qR7dLm3nXk…"}}}'
{
  "device_keys": {
    "user_id": "alice",
    "device_id": "dev-alice",
    "algorithms": [
      "m.olm.v1.curve25519-aes-sha2",
      "m.megolm.v1.aes-sha2"
    ],
    "keys": {
      "curve25519:dev-alice": "JGDn8Xz3…",
      "ed25519:dev-alice": "lEuiSJHm…"
    }
  },
  "one_time_keys": {
    "signed_curve25519:AAAAAQ": {
      "key": "zKbLg+NrIjpn…"
    },
    "signed_curve25519:AAAAAg": {
      "key": "mYWt2yTv8p…"
    },
    "signed_curve25519:AAAAAw": {
      "key": "qR7dLm3nXk…"
    }
  }
}
ParameterTypeDescription
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Request fields

FieldTypeDescription
device_keys object The device's identity keys, in the Matrix /keys/upload shape. At most 64 KiB.
one_time_keys object One-time keys to add, keyed <algorithm>:<key id> (Matrix shape). At most 100 per upload.
fallback_keys object Fallback keys, served when the one-time keys run out (Matrix shape).

Response · 200

{
  "one_time_key_counts": {
    "signed_curve25519": 3
  }
}
Response fields (2)
FieldTypeDescription
one_time_key_counts required OneTimeKeyCounts The device's remaining one-time keys after this upload, per algorithm.
one_time_key_counts.signed_curve25519 required integer (int64) How many unclaimed one-time keys the device has left on the server. Upload more before it runs low.
Errors · 400 · 401 · 403 · 429
StatusWhen
400missing did claim / oversized blob
401Missing, expired or invalid credentials
403Authenticated, but the token is not scoped to this room or principal
429Rate limit exceeded
POST /v1/e2ee/keys/query #

Query devices for principals

Limited to 60 calls per principal per 10 seconds. The token must carry an explicit device_id (400 otherwise). Every principal in the query must be the caller or share an active room with the caller; if any one does not, the whole request returns 403. The response contains all devices of each principal, whatever device list the request names. A principal with no uploaded keys is left out of the result.

keysQuery captured live

Request

curl -X POST https://api.ollacore.com/v1/e2ee/keys/query \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…" \
  -H "Content-Type: application/json" \
  -d '{"device_keys":{"alice":{},"bob":{}}}'
{
  "device_keys": {
    "alice": {},
    "bob": {}
  }
}
ParameterTypeDescription
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Request fields

FieldTypeDescription
device_keys required object principal -> {} (device filter ignored; all devices returned)

Response · 200

{
  "device_keys": {
    "alice": {
      "dev-alice": {
        "algorithms": [
          "m.olm.v1.curve25519-aes-sha2",
          "m.megolm.v1.aes-sha2"
        ],
        "device_id": "dev-alice",
        "keys": {
          "curve25519:dev-alice": "JGDn8Xz3…",
          "ed25519:dev-alice": "lEuiSJHm…"
        },
        "user_id": "alice"
      }
    },
    "bob": {
      "dev-bob": {
        "algorithms": [
          "m.olm.v1.curve25519-aes-sha2"
        ],
        "device_id": "dev-bob",
        "keys": {
          "curve25519:dev-bob": "aBcD…"
        },
        "user_id": "bob"
      }
    }
  }
}
Response fields (1)
FieldTypeDescription
device_keys required object principal id -> device id -> the device key object that principal published
Errors · 400 · 401 · 403 · 429
StatusWhen
400The token was minted without an explicit device_id.
401Missing, expired or invalid credentials
403no shared room
429Rate limit exceeded
POST /v1/e2ee/keys/claim #

Claim a one-time key (atomic)

Limited to 120 calls per principal per 10 seconds. The token must carry an explicit device_id (400 otherwise). For each requested device, the oldest unclaimed one-time key is deleted and returned. When none is left, the device's newest fallback key is returned instead and kept on file. It is marked as used, so it no longer appears in its owner's unused_fallback_key_algorithms, which tells the owner to replace it. Devices with no key at all are left out of the result, and the algorithm named in the request is ignored.

keysClaim captured live

Request

curl -X POST https://api.ollacore.com/v1/e2ee/keys/claim \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…" \
  -H "Content-Type: application/json" \
  -d '{"one_time_keys":{"bob":{"dev-bob":"signed_curve25519"}}}'
{
  "one_time_keys": {
    "bob": {
      "dev-bob": "signed_curve25519"
    }
  }
}
ParameterTypeDescription
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Request fields

FieldTypeDescription
one_time_keys required object principal -> device -> algorithm

Response · 200

{
  "one_time_keys": {
    "bob": {
      "dev-bob": {
        "signed_curve25519:BBBBAQ": {
          "key": "nnn…"
        }
      }
    }
  }
}
Response fields (1)
FieldTypeDescription
one_time_keys required object principal id -> device id -> { key_id: key }; a device is absent when its pool is dry and it published no fallback key
Errors · 400 · 401 · 403 · 429
StatusWhen
400The token was minted without an explicit device_id.
401Missing, expired or invalid credentials
403no shared room
429Rate limit exceeded
POST /v1/e2ee/todevice #

Send a to-device message

Limited to 60 calls per principal per 10 seconds. The token must carry an explicit device_id (400 otherwise). event_type must be 1–128 characters and each device's content at most 64 KiB. After * is expanded to every device the recipient has uploaded keys for, one request may queue at most 500 rows (400 otherwise). If any recipient is neither the caller nor shares an active room with the caller, the whole request returns 403. Queued events wait in each device's inbox until that device fetches and acknowledges them through GET /v1/e2ee/todevice; no socket event announces them. Events never fetched are purged after 30 days.

todeviceSend captured live

Request

curl -X POST https://api.ollacore.com/v1/e2ee/todevice \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…" \
  -H "Content-Type: application/json" \
  -d '{"event_type":"m.room_key_request","messages":{"bob":{"dev-bob":{"action":"request","requesting_device_id":"dev-alice"}}}}'
{
  "event_type": "m.room_key_request",
  "messages": {
    "bob": {
      "dev-bob": {
        "action": "request",
        "requesting_device_id": "dev-alice"
      }
    }
  }
}
ParameterTypeDescription
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Request fields

FieldTypeDescription
event_type required string The to-device event type, 1–128 characters, for example m.room.encrypted.
messages required object principal -> device|* -> content

Response · 200

{
  "queued": 1
}
Response fields (1)
FieldTypeDescription
queued required integer (int64) per-device rows written; a wildcard recipient fans out to every known device
Errors · 400 · 401 · 403 · 429
StatusWhen
400The token was minted without an explicit device_id. Or an event_type outside 1–128 characters, a content over 64 KiB, or more than 500 rows once * is expanded.
401Missing, expired or invalid credentials
403Authenticated, but the token is not scoped to this room or principal
429Rate limit exceeded
GET /v1/e2ee/todevice #

Fetch queued to-device messages

The token must carry an explicit device_id (400 otherwise), and only that device's inbox is returned. Pass the previous response's next_token as ack: events up to it are deleted and the next batch of at most 100 events is returned, oldest first. An unacknowledged batch is therefore served again after a crash. Each event carries the server-stamped sender principal, its type and its content. Pass the previous device_list_watermark as device_watermark; device_list_changes then lists the caller and any co-members whose devices changed since. Undelivered events are purged after 30 days.

todeviceFetch captured live

Request

curl -X GET https://api.ollacore.com/v1/e2ee/todevice \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…"
ParameterTypeDescription
ack query integer (int64) default 0 next_token of the last fully processed batch
device_watermark query integer (int64) default 0 The device_list_watermark from your previous fetch; 0 (the default) on the first.
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Response · 200

{
  "device_list_changes": [
    "alice",
    "bob"
  ],
  "device_list_watermark": 157,
  "events": [
    {
      "content": {
        "action": "request",
        "requesting_device_id": "dev-alice"
      },
      "sender": "alice",
      "type": "m.room_key_request"
    }
  ],
  "next_token": 130,
  "one_time_key_counts": {
    "signed_curve25519": 1
  },
  "unused_fallback_key_algorithms": []
}
Response fields (10)
FieldTypeDescription
events required array of ToDeviceEvent Queued to-device events for this device, oldest first. Acknowledge them on the next fetch so they are deleted.
events[].sender required string server-stamped from the token, never taken from the request body
events[].type required string The event type the sender used, for example m.room.encrypted.
events[].content required object The event content as the sender wrote it. Opaque to the server.
next_token required integer (int64) pass back as ack once the batch is processed; an unacked batch is re-served
one_time_key_counts required OneTimeKeyCounts The device's remaining one-time keys, per algorithm.
one_time_key_counts.signed_curve25519 required integer (int64) How many unclaimed one-time keys the device has left on the server. Upload more before it runs low.
unused_fallback_key_algorithms required array of string Algorithms whose fallback key has not been used yet. Empty means upload a new fallback key.
device_list_changes required array of string principals whose device list changed since device_watermark
device_list_watermark required integer (int64) Pass this back as device_watermark on the next fetch to learn which users' device lists changed since.
Errors · 400 · 401 · 403 · 429
StatusWhen
400The token was minted without an explicit device_id.
401Missing, expired or invalid credentials
403The principal is no longer an active member of the token's room.
429Rate limit exceeded
POST /v1/keypackages #

Upload MLS key packages

The token's principal must still be an active member of the token's room. packages holds 1–50 base64 blobs, each decoding to between 1 byte and 64 KiB (400 otherwise). They are stored under device_id, which defaults to default when omitted and is not taken from the token. A package already on file (same SHA-256) is skipped, so stored counts only new packages. With last_resort: true the request must carry exactly one package, which replaces the device's previous last-resort package. expires_in_seconds (1 to 31,536,000, one year) sets how long the server keeps the package: after that it is never served and is swept. If omitted, the package is kept until it is consumed.

uploadKeyPackages captured live

Request

curl -X POST https://api.ollacore.com/v1/keypackages \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…" \
  -H "Content-Type: application/json" \
  -d '{"device_id":"dev-alice","packages":["AAECAwQFBgcICQoLDA0ODw==","EBESExQVFhcYGRobHB0eHw==","ICEiIyQlJicoKSorLC0uLw=="]}'
{
  "device_id": "dev-alice",
  "packages": [
    "AAECAwQFBgcICQoLDA0ODw==",
    "EBESExQVFhcYGRobHB0eHw==",
    "ICEiIyQlJicoKSorLC0uLw=="
  ]
}
ParameterTypeDescription
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Request fields

FieldTypeDescription
device_id string The device these key packages belong to. Defaults to default.
packages required array of string opaque base64 MLS key packages; never interpreted server-side
last_resort boolean default false true publishes the device's single last-resort package (RFC 9750): it is served when the device's regular pool is dry and is never consumed by being served. The request must carry exactly one package; a new last-resort upload replaces the previous one.
expires_in_seconds integer min 1 · max 31536000 Server-side lifetime. Past it the package is never handed out and is swept. Omit to keep the package until it is consumed.

Response · 200

{
  "stored": 3
}
Response fields (1)
FieldTypeDescription
stored required integer How many key packages were stored.
Errors · 400 · 401 · 403 · 413 · 429
StatusWhen
400No packages or more than 50, a blob that is not base64 or decodes outside 1 byte–64 KiB, a last-resort upload with other than one package, or expires_in_seconds out of range.
401Missing, expired or invalid credentials
403The principal is no longer an active member of the token's room.
413The request body is over 512 KiB.
429Rate limit exceeded
GET /v1/keypackages/count #

Remaining key-package count

The token's principal must still be an active member of the token's room. For each of the caller's devices that has packages on file, remaining counts the unexpired, unconsumed regular packages, and last_resort says whether an unexpired last-resort package is held. A device with nothing left does not appear in devices. When remaining reaches zero, consumers receive the reusable last-resort package, so replenish before that.

keyPackageCount captured live

Request

curl -X GET https://api.ollacore.com/v1/keypackages/count \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…"
ParameterTypeDescription
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Response · 200

{
  "devices": [
    {
      "device_id": "dev-alice",
      "last_resort": false,
      "remaining": 3
    }
  ]
}
Response fields (4)
FieldTypeDescription
devices required array of object Your devices that have key packages on the server.
devices[].device_id required string The device.
devices[].remaining required integer How many unconsumed, unexpired regular key packages it has left. The last-resort package is not counted here.
devices[].last_resort boolean Whether an unexpired last-resort package is on file for this device. Always present.
Errors · 401 · 403 · 429
StatusWhen
401Missing, expired or invalid credentials
403The principal is no longer an active member of the token's room.
429Rate limit exceeded
GET /v1/rooms/{room_id}/keypackages/{principal_id} #

Consume one key package

Both the caller and the target principal must be active members of this room (403 otherwise). For each of the target's devices, the oldest unexpired regular package is consumed and returned. A device with none left returns its last-resort package with last_resort: true; that package stays on file and can be served again. Expired packages are never served. The call returns 404 when none of the target's devices has any package.

consumeKeyPackages captured live

Request

curl -X GET https://api.ollacore.com/v1/rooms/25964f12-9ae9-4356-80e8-69bc5f749bd1/keypackages/alice \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…"
ParameterTypeDescription
room_id path required string (uuid) The room's id. It must be the room the session token was minted for (403 otherwise), and the caller must still be an active member.
principal_id path required string Whose key package to consume. They must share an active room with the caller.
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Response · 200

{
  "principal_id": "alice",
  "packages": [
    {
      "device_id": "dev-alice",
      "package": "AAECAwQFBgcICQoLDA0ODw==",
      "last_resort": false
    }
  ]
}
Response fields (5)
FieldTypeDescription
principal_id required string Whose key packages these are.
packages required array of ConsumedKeyPackage At most one package per device of the principal, oldest first. A regular package is handed out once: it is marked consumed and never served again. When a device's regular pool is dry its last-resort package is served instead, unconsumed, and flagged last_resort: true. Expired packages are never served.
packages[].device_id required string The device this key package belongs to.
packages[].package required string The MLS key package, base64-encoded. Opaque to the server.
packages[].last_resort boolean true when this is the device's last-resort package, served because its regular pool was dry. Other joiners may have received the same one; expect the owner to replace it. Always present.
Errors · 401 · 403 · 404 · 429
StatusWhen
401Missing, expired or invalid credentials
403The caller or the target is not an active member of this room.
404No such room, message, member or attachment
429Rate limit exceeded
PUT /v1/rooms/{room_id}/mls/group-info #

Publish the MLS GroupInfo + ratchet tree

Any active member may publish; a room that is not e2ee returns 409. epoch must be 0 or more. group_info (base64, up to 64 KiB) and the optional ratchet_tree (base64, up to 256 KiB) are stored as a single row per room that each publish replaces. Omitting ratchet_tree clears any previously stored tree. Oversized or non-base64 blobs return 400. Publishing the same epoch again is accepted, so a committer can retry safely. Nothing is published to the room's sockets.

putGroupInfo captured live

Request

curl -X PUT https://api.ollacore.com/v1/rooms/f10b8ba1-6206-4994-9e66-597cef0a6792/mls/group-info \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…" \
  -H "Content-Type: application/json" \
  -d '{"epoch":3,"group_info":"AAECAwQFBgcICQoLDA0ODw==","ratchet_tree":"EBESExQVFhcYGRobHB0eHw=="}'
{
  "epoch": 3,
  "group_info": "AAECAwQFBgcICQoLDA0ODw==",
  "ratchet_tree": "EBESExQVFhcYGRobHB0eHw=="
}
ParameterTypeDescription
room_id path required string (uuid) The room's id. It must be the room the session token was minted for (403 otherwise), and the caller must still be an active member.
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Request fields

FieldTypeDescription
epoch required integer (int64) min 0 The MLS epoch this GroupInfo describes. An epoch older than the one already published is refused with 409; the same epoch may be re-published.
group_info required string base64 TLS-serialized GroupInfo (an MLSMessage of type group_info). Opaque to the server; at most 64 KiB.
ratchet_tree string base64 TLS-serialized ratchet tree, so Welcomes can omit the ratchet_tree extension. Opaque; at most 256 KiB — about a hundred members on a post-quantum cipher suite.

Response · 200

{
  "epoch": 3
}
Response fields (1)
FieldTypeDescription
epoch required integer (int64) The epoch now published.
Errors · 400 · 401 · 403 · 409 · 413 · 429
StatusWhen
400A blob that is not base64 or is over its limit (64 KiB GroupInfo, 256 KiB ratchet tree), or a negative epoch.
401Missing, expired or invalid credentials
403Authenticated, but the token is not scoped to this room or principal
409Conflicts with current state
413The request body is over 512 KiB.
429Rate limit exceeded
GET /v1/rooms/{room_id}/mls/group-info #

Latest GroupInfo + ratchet tree

Any active member may read it. Returns the stored epoch, the base64 group_info and ratchet_tree, and updated_by and updated_at for the latest publish. ratchet_tree is null when the publisher omitted it. The call returns 404 until a member has published GroupInfo for this room, which means a room that is not e2ee always returns 404.

getGroupInfo captured live

Request

curl -X GET https://api.ollacore.com/v1/rooms/f10b8ba1-6206-4994-9e66-597cef0a6792/mls/group-info \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…"
ParameterTypeDescription
room_id path required string (uuid) The room's id. It must be the room the session token was minted for (403 otherwise), and the caller must still be an active member.
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Response · 200

{
  "epoch": 3,
  "group_info": "AAECAwQFBgcICQoLDA0ODw==",
  "ratchet_tree": "EBESExQVFhcYGRobHB0eHw==",
  "updated_by": "alice",
  "updated_at": "2026-01-15T09:30:53Z"
}
Response fields (5)
FieldTypeDescription
epoch required integer (int64) The epoch the published state describes.
group_info required string base64 TLS-serialized GroupInfo, exactly as published.
ratchet_tree string base64 TLS-serialized ratchet tree, when the committer published one.
updated_by required string The principal who published it.
updated_at required string (date-time)
Errors · 401 · 403 · 404 · 429
StatusWhen
401Missing, expired or invalid credentials
403Authenticated, but the token is not scoped to this room or principal
404No such room, message, member or attachment
429Rate limit exceeded

Experimental #

POST /experimental/rtc/session #

Observe your own accepted RTC session by its handle — not GA, and absent unless the deployment enables it

Not stable or GA. RTC_SESSION_CONTROL_ENABLED defaults to false; only literal true registers this route, otherwise POST returns 404. The socket must separately opt in with exactly one literal control=1 on /v1/rtc/ws (duplicate or encoded control keys/values do not enable it). Its private session.handle event follows initial peers and is never broadcast. This HTTP request requires Authorization: Bearer, rtc:subscribe and exact equality to the socket's stored claims: tid, rid, sub, did (absence differs from empty), jti, exp, iss, aud, role, permissions (including order), iat and nbf. WebSocket subprotocol credentials alone do not authenticate HTTP. A renewed JWT requires a new opted-in WebSocket and handle. Expiry is strict at exp, without verifier leeway. After exact ownership and nonblocking read permits, a fresh active-membership query must succeed before the media-thread exact-session/port/epoch/writer check; HTTP rechecks expiry and ownership before responding. Membership observation is NOT atomic with concurrent membership removal. accepted is admission only, not connected transport or media health. No mutations or room/source inventory. All responses for this exact path are Cache-Control: no-store. A shared two-second deadline covers the read; pressure/unavailability is 503, deadline expiry is 504.

read · scope rtc:subscribe from schema placeholder credentials

Request

curl -X POST https://api.ollacore.com/experimental/rtc/session \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…" \
  -H "Content-Type: application/json" \
  -d '{"handle":"00000000-0000-0000-0000-000000000000"}'
{
  "handle": "00000000-0000-0000-0000-000000000000"
}
ParameterTypeDescription
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Request fields

FieldTypeDescription
handle required string (uuid) min length 36 · max length 36 Opaque UUID from the private session.handle event; hex may be either case. Not a credential, client_id or internal lease ID.

Response · 200

{
  "client_id": "7",
  "state": "accepted"
}
Response fields (2)
FieldTypeDescription
client_id required string The exact accepted signaling connection's u64 identifier as a decimal string, never a JSON number.
state required one of: accepted Admission observed on the owning media thread; not a media-health guarantee or atomic membership-removal fence.
Errors · 400 · 401 · 403 · 404 · 413 · 503 · 504
StatusWhen
400invalid_request: malformed JSON or not exactly one hyphenated UUID handle
401unauthorized: missing/invalid bearer or token expired (no expiry leeway)
403forbidden: missing rtc:subscribe or no longer an active member
404not_found: route disabled, unknown/non-owned handle, or session retired/no longer current; renewed JWT claims do not own an old handle
413payload_too_large: body collection fails or exceeds 256 bytes
503unavailable: engine/control lane unavailable, global/per-session read pressure, or membership lookup failure
504deadline_exceeded: shared two-second read budget expired
Synthetic wire-shape illustration from Rust contracts, not a captured or runtime-verified result. The all-zero UUID and client_id 7 are non-secret fixtures, not an issued session or a usable request/response pair. Experimental READ-ONLY; not stable or GA. RTC_SESSION_CONTROL_ENABLED defaults to false (POST returns 404). Requires literal true plus exactly one literal control=1 on a newly accepted RTC WebSocket; duplicates or encoded control keys/values do not enable it. Use the private handle received after peers and the matching HTTP bearer with rtc:subscribe. Every stored claim must match, including tid/rid/sub/did/jti/exp; renewal requires a new WebSocket/handle. No JWT is included in this fixture. accepted is admission only, not media health. Fresh membership precedes the media-thread check but is not atomic with concurrent membership removal. All responses for the exact path are no-store.

Deprecated #

GET /v1/rooms/{room_id}/recordings #

Sunset 2027-01-05 — camera feeds were withdrawn; always an empty list, with Deprecation and Sunset headers

DEPRECATED: the camera-feeds media sidecar that produced recordings has been decommissioned, so this returns an empty list. Responses carry Deprecation and Sunset headers; it may be removed on or after 2027-01-05. See docs/API-STABILITY.md. Segments of a recorded camera, newest first, each a fragmented MP4 playable on its own or in order. A camera is recorded by publishing to rec/{room_id} instead of {room_id}; nothing here turns recording on. Whoever may watch the camera may watch what it recorded: the same rtc:subscribe permission and the same live membership. Footage is deleted on the deployment’s retention schedule and by every erasure path, so treat these as a secondary copy of what the camera’s own recorder holds.

listRecordings captured live

Request

curl -X GET https://api.ollacore.com/v1/rooms/25964f12-9ae9-4356-80e8-69bc5f749bd1/recordings?limit=20 \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQSJ9…"
ParameterTypeDescription
room_id path required string (uuid) The camera's room id. It must be the room the session token was minted for (403 otherwise), and the caller must still be an active member.
from query string (date-time) Only segments starting at or after this instant.
to query string (date-time) Only segments starting before this instant.
limit query integer default 100 · min 1 · max 500 Maximum segments, counted back from the newest.
X-Request-Id header string Your own id for this request. It is returned in the response's X-Request-Id header and attached to every log line kept for the request, so quote it when you contact support. Use printable ASCII. Without it, the server assigns a UUID.

Response · 200

{
  "segments": []
}
Response fields (5)
FieldTypeDescription
segments required array of RecordingSegment Newest first. Empty when the camera has recorded nothing in the window, and on a deployment with no recording store.
segments[].started_at required string (date-time) When this segment's first frame was recorded. Segments run up to a minute; a gap between one segment's start and the next means the camera was not publishing.
segments[].bytes required integer (int64) The stored size of the segment, which is what it counts against the tenant’s recording storage.
segments[].download_url required string A signed link to the fragmented-MP4 segment, valid for 5 minutes. Fetch the list again for a fresh one rather than storing these.
segments[].expires_at required string (date-time) When download_url stops working.
Errors · 401 · 403 · 429
StatusWhen
401Missing, expired or invalid credentials
403Authenticated, but the token is not scoped to this room or principal
429Rate limit exceeded
Deprecated since 2026-10-03 (camera feeds were decommissioned): always an empty list, with Deprecation and Sunset headers on every response. May be removed on or after 2027-01-05.

Errors on this plane #

Every failure uses the same body — code and message. These were triggered deliberately against the live API.

401 · no Authorization header

{
  "code": "unauthorized",
  "message": "unauthorized: missing Authorization header"
}

401 · malformed bearer token

{
  "code": "unauthorized",
  "message": "unauthorized: invalid token: malformed"
}

The full code list and retry guidance is on Errors & rate limits.