What your app calls directly. Every path is authorised against the token’s room and principal.
30 operations · 30 captured against the live API.
Messages #
POST /v1/rooms/{room_id}/messages # Send
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 (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.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 · 401 · 402 · 403 · 413 · 429
| Status | When |
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. |
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)
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",
"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 (27)
| 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[].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)
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 (26)
| 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.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
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 (26)
| 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.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
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 (27)
| 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[].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 · 409 · 429
| Status | When |
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
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
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 · 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 |
429 | Rate limit exceeded |
Receipts & presence #
POST /v1/rooms/{room_id}/messages/{message_id}/delivered # Delivery receipt
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)
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
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
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
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, audio/ogg, audio/mpeg, audio/mp4, video/mp4, video/webm, application/pdf, text/plain, application/zip | The file's type. It must be on the allowlist, and the server checks the uploaded bytes match it. 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 · 401 · 402 · 403 · 413 · 415 · 429
| Status | When |
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 | Body exceeds MAX_UPLOAD_BYTES or the per-message limit |
415 | Blocked or unknown type |
429 | Rate limit exceeded |
POST /v1/rooms/{room_id}/attachments/{attachment_id}/complete # Finish and verify
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 | Not uploaded / size mismatch |
429 | Rate limit exceeded |
POST /v1/rooms/{room_id}/attachments/init-multipart # Begin a multipart upload
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 · 401 · 402 · 403 · 413 · 415 · 429
| Status | When |
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 | declared size exceeds the configured limit |
415 | declared type not permitted |
429 | Rate limit exceeded |
POST /v1/rooms/{room_id}/attachments/{attachment_id}/complete-multipart # Finish multipart
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 · 429
| Status | When |
400 | part list does not match the upload |
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}/attachments/{attachment_id}/download # Presigned download
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 · 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 |
Push #
POST /v1/push/subscriptions # Register a Web Push subscription
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 · 401 · 429
| Status | When |
401 | Missing, expired or invalid credentials |
429 | Rate limit exceeded |
DELETE /v1/push/subscriptions # Unregister one
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
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
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
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 · 401 · 403 · 429
| Status | When |
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)
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 · 401 · 403 · 429
| Status | When |
401 | Missing, expired or invalid credentials |
403 | no shared room |
429 | Rate limit exceeded |
Send a to-device message
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 · 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 |
Fetch queued to-device messages
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 · 401 · 429
| Status | When |
401 | Missing, expired or invalid credentials |
429 | Rate limit exceeded |
Upload MLS key packages
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 · 401 · 413 · 429
| Status | When |
401 | Missing, expired or invalid credentials |
413 | Body exceeds MAX_UPLOAD_BYTES or the per-message limit |
429 | Rate limit exceeded |
GET /v1/keypackages/count # Remaining key-package count
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 · 429
| Status | When |
401 | Missing, expired or invalid credentials |
429 | Rate limit exceeded |
GET /v1/rooms/{room_id}/keypackages/{principal_id} # Consume one key 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 | no shared active room with the target |
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
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 · 401 · 403 · 409 · 413 · 429
| Status | When |
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 | Body exceeds MAX_UPLOAD_BYTES or the per-message limit |
429 | Rate limit exceeded |
GET /v1/rooms/{room_id}/mls/group-info # Latest GroupInfo + ratchet tree
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 |