What your app calls directly. Every path is authorised against the token’s room and principal.
31 operations · 30 captured against the live API.
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?"
}
}
| Parameter | Type | Description |
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
| Field | Type | Description |
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)
| Field | Type | Description |
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
| Status | When |
400 | A bad kind, client_message_id, mentions, reply_to or forward_of. |
401 | Missing, expired or invalid credentials |
402 | The 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. |
403 | Authenticated 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. |
409 | A 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. |
413 | Body exceeds MAX_UPLOAD_BYTES or the per-message limit |
429 | Rate 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…"
| Parameter | Type | Description |
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)
| Field | Type | Description |
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
| Status | When |
401 | Missing, expired or invalid credentials |
403 | Authenticated, but the token is not scoped to this room or principal |
429 | Rate 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?"
}
}
| Parameter | Type | Description |
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
| Field | Type | Description |
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)
| Field | Type | Description |
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
| Status | When |
401 | Missing, expired or invalid credentials |
403 | Authenticated, but the token is not scoped to this room or principal |
404 | No such room, message, member or attachment |
409 | Conflicts with current state |
429 | Rate 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…"
| Parameter | Type | Description |
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)
| Field | Type | Description |
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
| Status | When |
401 | Missing, expired or invalid credentials |
403 | Authenticated, but the token is not scoped to this room or principal |
404 | No such room, message, member or attachment |
429 | Rate 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…"
| Parameter | Type | Description |
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)
| Field | Type | Description |
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
| Status | When |
400 | q is empty or longer than 256 bytes after trimming. |
401 | Missing, expired or invalid credentials |
403 | Authenticated, but the token is not scoped to this room or principal |
409 | room is e2ee; the server cannot search ciphertext |
429 | Rate 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": "👍"
}
| Parameter | Type | Description |
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
| Field | Type | Description |
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)
| Field | Type | Description |
changed required | boolean | false when the call was a no-op, which makes retries safe |
Errors · 401 · 403 · 404 · 409 · 429
| Status | When |
401 | Missing, expired or invalid credentials |
403 | Authenticated 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. |
404 | No such room, message, member or attachment |
409 | room is e2ee |
429 | Rate 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": "👍"
}
| Parameter | Type | Description |
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
| Field | Type | Description |
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)
| Field | Type | Description |
changed required | boolean | false when the call was a no-op, which makes retries safe |
Errors · 401 · 403 · 404 · 409 · 429
| Status | When |
401 | Missing, expired or invalid credentials |
403 | Authenticated 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. |
404 | No such room, message, member or attachment |
409 | The room is e2ee. |
429 | Rate 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…"
| Parameter | Type | Description |
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)
| Field | Type | Description |
advanced required | boolean | false when the cursor already covered this message, which makes retries safe |
Errors · 401 · 403 · 404 · 429
| Status | When |
401 | Missing, expired or invalid credentials |
403 | Authenticated, but the token is not scoped to this room or principal |
404 | No such room, message, member or attachment |
429 | Rate 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…"
| Parameter | Type | Description |
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)
| Field | Type | Description |
advanced required | boolean | false when the cursor already covered this message, which makes retries safe |
Errors · 401 · 403 · 404 · 429
| Status | When |
401 | Missing, expired or invalid credentials |
403 | Authenticated, but the token is not scoped to this room or principal |
404 | No such room, message, member or attachment |
429 | Rate 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…"
| Parameter | Type | Description |
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)
| Field | Type | Description |
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
| Status | When |
401 | Missing, expired or invalid credentials |
403 | Authenticated, but the token is not scoped to this room or principal |
429 | Rate 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…"
| Parameter | Type | Description |
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)
| Field | Type | Description |
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
| Status | When |
401 | Missing, expired or invalid credentials |
403 | Authenticated, but the token is not scoped to this room or principal |
429 | Rate 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
}
| Parameter | Type | Description |
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
| Field | Type | Description |
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)
| Field | Type | Description |
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
| Status | When |
400 | byte_size outside 1 to the deployment's upload limit, or a bad original_name or poster_of. |
401 | Missing, expired or invalid credentials |
402 | This 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. |
403 | Authenticated, but the token is not scoped to this room or principal |
413 | The request body is over 512 KiB. |
415 | Blocked or unknown type |
429 | Rate 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 '{}'
{}
| Parameter | Type | Description |
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)
| Field | Type | Description |
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
| Status | When |
401 | Missing, expired or invalid credentials |
403 | Authenticated, but the token is not scoped to this room or principal |
404 | No such room, message, member or attachment |
409 | The object is not uploaded yet or its size differs from byte_size, or the attachment is already verifying or failed. |
429 | Rate 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
}
| Parameter | Type | Description |
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
| Field | Type | Description |
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)
| Field | Type | Description |
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
| Status | When |
400 | byte_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. |
401 | Missing, expired or invalid credentials |
402 | This 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. |
403 | Authenticated, but the token is not scoped to this room or principal |
413 | The request body is over 512 KiB. |
415 | declared type not permitted |
429 | Rate 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"
}
]
}
| Parameter | Type | Description |
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
| Field | Type | Description |
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)
| Field | Type | Description |
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
| Status | When |
400 | An empty parts list, or one over 512. |
401 | Missing, expired or invalid credentials |
403 | Authenticated, but the token is not scoped to this room or principal |
404 | No such room, message, member or attachment |
409 | The 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. |
429 | Rate 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…"
| Parameter | Type | Description |
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)
| Field | Type | Description |
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
| Status | When |
401 | Missing, expired or invalid credentials |
403 | Authenticated, but the token is not scoped to this room or principal |
404 | No such room, message, member or attachment |
409 | The attachment is not ready: still verifying, or failed. |
429 | Rate 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)"
}
| Parameter | Type | Description |
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
| Field | Type | Description |
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)
| Field | Type | Description |
subscribed required | boolean | Always true on success. |
Errors · 400 · 401 · 403 · 429
| Status | When |
400 | endpoint is not https, is over 1024 characters, or names a private, loopback or link-local address. |
401 | Missing, expired or invalid credentials |
403 | The principal is no longer an active member of the token's room. |
429 | Rate 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"
}
| Parameter | Type | Description |
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
| Field | Type | Description |
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)
| Field | Type | Description |
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
| Status | When |
401 | Missing, expired or invalid credentials |
429 | Rate 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…"
| Parameter | Type | Description |
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)
| Field | Type | Description |
vapid_public_key required | string | The server's VAPID public key. Pass it as applicationServerKey to pushManager.subscribe. |
Errors · 404
| Status | When |
404 | push 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…"
}
}
}
| Parameter | Type | Description |
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
| Field | Type | Description |
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)
| Field | Type | Description |
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
| Status | When |
400 | missing did claim / oversized blob |
401 | Missing, expired or invalid credentials |
403 | Authenticated, but the token is not scoped to this room or principal |
429 | Rate 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": {}
}
}
| Parameter | Type | Description |
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
| Field | Type | Description |
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)
| Field | Type | Description |
device_keys required | object | principal id -> device id -> the device key object that principal published |
Errors · 400 · 401 · 403 · 429
| Status | When |
400 | The token was minted without an explicit device_id. |
401 | Missing, expired or invalid credentials |
403 | no shared room |
429 | Rate 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"
}
}
}
| Parameter | Type | Description |
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
| Field | Type | Description |
one_time_keys required | object | principal -> device -> algorithm |
Response · 200
{
"one_time_keys": {
"bob": {
"dev-bob": {
"signed_curve25519:BBBBAQ": {
"key": "nnn…"
}
}
}
}
}
Response fields (1)
| Field | Type | Description |
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
| Status | When |
400 | The token was minted without an explicit device_id. |
401 | Missing, expired or invalid credentials |
403 | no shared room |
429 | Rate limit exceeded |
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"
}
}
}
}
| Parameter | Type | Description |
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
| Field | Type | Description |
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)
| Field | Type | Description |
queued required | integer (int64) | per-device rows written; a wildcard recipient fans out to every known device |
Errors · 400 · 401 · 403 · 429
| Status | When |
400 | The 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. |
401 | Missing, expired or invalid credentials |
403 | Authenticated, but the token is not scoped to this room or principal |
429 | Rate limit exceeded |
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…"
| Parameter | Type | Description |
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)
| Field | Type | Description |
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
| Status | When |
400 | The token was minted without an explicit device_id. |
401 | Missing, expired or invalid credentials |
403 | The principal is no longer an active member of the token's room. |
429 | Rate limit exceeded |
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=="
]
}
| Parameter | Type | Description |
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
| Field | Type | Description |
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)
| Field | Type | Description |
stored required | integer | How many key packages were stored. |
Errors · 400 · 401 · 403 · 413 · 429
| Status | When |
400 | No 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. |
401 | Missing, expired or invalid credentials |
403 | The principal is no longer an active member of the token's room. |
413 | The request body is over 512 KiB. |
429 | Rate 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…"
| Parameter | Type | Description |
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)
| Field | Type | Description |
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
| Status | When |
401 | Missing, expired or invalid credentials |
403 | The principal is no longer an active member of the token's room. |
429 | Rate 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…"
| Parameter | Type | Description |
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)
| Field | Type | Description |
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
| Status | When |
401 | Missing, expired or invalid credentials |
403 | The caller or the target is not an active member of this room. |
404 | No such room, message, member or attachment |
429 | Rate 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=="
}
| Parameter | Type | Description |
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
| Field | Type | Description |
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)
| Field | Type | Description |
epoch required | integer (int64) | The epoch now published. |
Errors · 400 · 401 · 403 · 409 · 413 · 429
| Status | When |
400 | A blob that is not base64 or is over its limit (64 KiB GroupInfo, 256 KiB ratchet tree), or a negative epoch. |
401 | Missing, expired or invalid credentials |
403 | Authenticated, but the token is not scoped to this room or principal |
409 | Conflicts with current state |
413 | The request body is over 512 KiB. |
429 | Rate 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…"
| Parameter | Type | Description |
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)
| Field | Type | Description |
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
| Status | When |
401 | Missing, expired or invalid credentials |
403 | Authenticated, but the token is not scoped to this room or principal |
404 | No such room, message, member or attachment |
429 | Rate limit exceeded |