Ollasync API Reference

Ollasync API

A blind end-to-end-encrypted messaging engine you sell as infrastructure. Your servers store and order ciphertext; they never hold keys or see plaintext. Clients (web, iOS, Android, CLI — any language) run an open MLS (RFC 9420) stack and exchange opaque blobs through these endpoints.

What the server does
  • Authenticates tenants & members (JWT / API keys)
  • Stores & orders opaque message blobs per room
  • Fans out delivery (poll or WebSocket)
  • Meters usage, enforces isolation & moderation
What the server never sees
  • Message plaintext, file contents, group keys
  • Anything inside a blob — it is MLS ciphertext
  • Your users' identity private keys

There are three planes: the Account API (you, the customer — sign up, get API keys, mint tokens, see usage), the Room & messaging API (your end-users — the blind transport), and the Deal-room workflow (parties, NDA gating, a role-gated document vault) layered on top.

Base URL & format#

All endpoints live under your deployment's HTTPS origin. Examples on this page use your current origin:

__BASE__/v1/...
  • TLS 1.3 only; HTTP/2.
  • Requests & responses are JSON (Content-Type: application/json), except signed attachment PUT/GET.
  • Binary MLS material (key packages, message blobs) is base64 (standard encoding) inside JSON.
  • IDs (room, member) are URL-safe slugs: letters, digits, -, _, . (validated server-side).
  • Timestamps are Unix seconds (integers).
  • Liveness: GET /healthz → 200. Prometheus metrics at /metrics (bearer DEALROOM_METRICS_TOKEN, required — 401 until it is set; never public).

Authentication#

Every non-public call carries a bearer credential. There are three kinds, each for a different actor:

CredentialHeaderWho holds itScopes
Dashboard sessionAuthorization: Bearer <jwt>Your team (dashboard login)Account API only (typ=dash)
API keyX-API-Key: sk_live_…
or Authorization: Bearer sk_live_…
Your backend (server-to-server)Mint room tokens; metered per org
Room tokenAuthorization: Bearer <jwt>One end-user, one roomThat room only; role member or principal

Room tokens are short-lived HS256 JWTs scoped to a single room and member. You mint them server-side from an API key (below) — the standard pattern: your backend authenticates your user, then calls /v1/account/token to get a room token for them. A caller can only ever act as the member named in their token.

Roles. principal is the room creator/admin (can add/remove members, moderate, gate NDAs, read the audit trail). member is a normal participant. Principal-only routes return 403 principal role required otherwise.

Tenant isolation#

Every room is owned by exactly one organization, bound on first use (first-wins). The rule, enforced on every room route and the WebSocket:

A token may only reach a room whose owning org matches the token's org. Mismatches fail closed with 403 token not valid for this org.
  • When your backend mints a room token (/v1/account/token), the room is claimed for your org. Another org minting into the same room id gets 403 room belongs to another organization.
  • Rooms created with no credential (the open demo path) are un-owned and reachable only by un-owned tokens — they are sealed off from org-owned rooms.
  • Isolation is independent of the JWT signature: even a correctly-signed token with the wrong org claim is rejected.

Quickstart#

From zero to a delivered ciphertext in five calls. 1 and 2 are one-time setup; 3–5 are your per-user runtime.

1 · create an org + dashboard session
curl -X POST __BASE__/v1/account/signup \
  -H "content-type: application/json" \
  -d '{"org_name":"Acme Capital","email":"[email protected]","password":"hunter2hunter2"}'
# → 201 {"token":"<dash-jwt>","org_id":"org_…","email":"[email protected]"}
2 · create an API key (shown once)
curl -X POST __BASE__/v1/account/keys \
  -H "authorization: Bearer <dash-jwt>" \
  -H "content-type: application/json" -d '{"name":"production"}'
# → 201 {"key":"sk_live_…","key_id":"key_…","prefix":"sk_live_xxxxxxx"}
3 · mint a room token for your user (server-to-server)
curl -X POST __BASE__/v1/account/token \
  -H "X-API-Key: sk_live_…" \
  -H "content-type: application/json" \
  -d '{"room":"deal-1","member":"alice","role":"principal"}'
# → 200 {"token":"<room-jwt>"}  (room "deal-1" is now owned by your org)
4 · your user creates the room
curl -X POST __BASE__/v1/rooms \
  -H "authorization: Bearer <room-jwt>" \
  -H "content-type: application/json" \
  -d '{"room_id":"deal-1","creator_id":"alice"}'
5 · send an (already-encrypted) message blob
curl -X POST __BASE__/v1/rooms/deal-1/messages \
  -H "authorization: Bearer <room-jwt>" \
  -H "content-type: application/json" \
  -d '{"sender_id":"alice","kind":"application","epoch":1,"blob":"<base64-mls-ciphertext>"}'
The crypto is yours. Step 5's blob is opaque MLS ciphertext your client produced. The server never decrypts it. See Building a client.

Account API · sign up & log in#

POST/v1/account/signupPublic

Create an organization and its first (owner) user; returns a dashboard session token.

FieldTypeNotes
org_namestringrequiredDisplay name
emailstringrequiredUnique; 409 if taken
passwordstringrequired8+ chars
201 response
{"token":"<dash-jwt>","org_id":"org_3f9a1c","email":"[email protected]"}
POST/v1/account/loginPublic

Exchange {email, password} for a dashboard session token. 401 on bad credentials.

{"token":"<dash-jwt>","org_id":"org_3f9a1c","email":"[email protected]"}
GET/v1/account/meDashboard
{"user_id":"usr_…","email":"[email protected]","org_id":"org_…","org_name":"Acme Capital","plan":"free","role":"owner"}
POST/v1/account/logoutDashboard

Revokes the current dashboard session (its jti). Returns 204.

Account API · API keys#

POST/v1/account/keysDashboard

Mint a new sk_live_ API key. The full secret is returned once — store it now; only its prefix + hash are kept.

FieldTypeNotes
namestringoptionalLabel
scopesstring[]optionalScopes: token:mint, account:read. Empty = full access. A key missing token:mint gets 403 on the token endpoints.
201 response
{"key":"sk_live_c48758…","key_id":"key_a1b2c3","name":"production","prefix":"sk_live_c48758","scopes":["token:mint"]}
GET/v1/account/keysDashboard

List your org's keys (secret never returned).

{"keys":[{"key_id":"key_a1b2c3","name":"production","prefix":"sk_live_c48758","created_at":1718500000,"last_used":1718540000,"revoked":false}]}
DELETE/v1/account/keys/{id}Dashboard

Revoke a key immediately. 204 on success, 404 if it isn't yours.

Account API · mint a room token#

POST/v1/account/tokenAPI key

Server-to-server: your backend mints a room-scoped token for one of your end-users (the Stream/Sendbird pattern). Binds the room to your org on first mint, and meters tokens_minted.

FieldTypeNotes
roomstringrequiredRoom id
memberstringrequiredYour user's id (token's sub)
rolestringoptionalmember (default) or principal
200 response — token valid 1h
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
403 room belongs to another organization — the room id is already owned by a different tenant. Pick a namespaced room id (e.g. prefix with your org).
POST/v1/account/token/bulkAPI key

Mint room tokens for many members of one room in a single call (seed a group). Requires token:mint. Supports Idempotency-Key.

request
{"room":"deal-1","members":[{"member":"alice","role":"principal"},{"member":"bob"},{"member":"carol"}]}
200 response
{"tokens":[{"member":"alice","token":"…"},{"member":"bob","token":"…"},{"member":"carol","token":"…"}]}

Account API · webhooks#

Subscribe a URL to room events. Payloads are metadata only (never ciphertext) and HMAC-SHA256 signed with the endpoint secret. Events: message.created, member.joined, call.started, nda.accepted (empty events = all).

POST/v1/account/webhooksDashboard

Create an endpoint. The signing secret is returned once. The URL must be public HTTPS (loopback/private rejected, SSRF guard).

request → 201
{"url":"https://api.acme.com/hooks/ollasync","events":["message.created"]}
→ {"id":"wh_…","url":"…","secret":"whsec_…","events":["message.created"],"created_at":…}
GET/v1/account/webhooksDashboard

List endpoints (secret never returned).

DELETE/v1/account/webhooks/{id}Dashboard

Remove an endpoint. 204.

POST/v1/account/webhooks/{id}/testDashboard

Fire a signed ping sample event to the endpoint (verify your HMAC check). 202.

GET/v1/account/webhooks/{id}/deliveriesDashboard

Recent delivery outcomes (last 50): event, HTTP code, ok, error, timestamp.

{"deliveries":[{"event":"message.created","code":200,"ok":true,"at":1718500000}]}
POST/v1/account/webhooks/{id}/rotate-secretDashboard

Issue a new signing secret (returned once). {"secret":"whsec_…"}.

Event catalog: message.created · message.redacted · member.joined · member.removed · call.started · poll.closed · nda.accepted · recording.started · recording.completed · recording.failed.

Verifying a delivery

Each POST carries X-Ollasync-Event, X-Ollasync-Delivery (the webhook id), and X-Ollasync-Signature: sha256=<hex>. Recompute and compare:

sig = "sha256=" + hmac_sha256_hex(webhook_secret, raw_request_body)
verify_constant_time(sig, header["X-Ollasync-Signature"])

For backward compatibility the legacy X-SecureDeal-Event / -Delivery / -Signature headers are still sent alongside the X-Ollasync-* ones — prefer the new names.

example payload
{"event":"message.created","org":"org_…","room":"deal-1","ts":1718500000,
 "data":{"seq":42,"sender_id":"alice","kind":"application","epoch":1}}

Delivery is async with one retry. A durable redrive queue is a planned hardening.

Account API · team members#

An org owner/admin invites teammates by email + role (admin | dev). These are dashboard roles (who can manage the account) — not room roles.

POST/v1/account/team/invitesDashboard

Owner/admin only. Body {email, role}. Returns an invite_token (emailed in production).

POST/v1/account/team/acceptPublic

Invitee accepts. Body {token, password} → a dashboard session for the new member.

GET/v1/account/teamDashboard

List org members (id, email, role).

DELETE/v1/account/team/{user_id}Dashboard

Owner only; removes a teammate. 204.

Hardening

GET/v1/account/auditDashboard

Dashboard action trail (key/webhook/team/login/origin changes).

{"events":[{"actor":"usr_…","action":"key_create","target":"key_…","at":1718500000}]}
POST/v1/account/verify/requestDashboard

Issue an email-verify token (emailed in production; returned in-body in the demo). Then POST /v1/account/verify {token} → 204; me.verified flips true.

PUT/v1/account/originsDashboard

Set the org's WebSocket origin allowlist: {origins:["https://app.acme.com"]}. Enforced on connect for that org's rooms (GET to read).

Login is brute-force-guarded: 5 failed attempts → a 5-minute lockout (429). Each API key tracks its lifetime requests + last_used (in the keys list).

Account API · usage & billing#

GET/v1/account/usageDashboard
{"org_id":"org_…","usage":{"api_requests":1402,"tokens_minted":318}}
GET/v1/account/billingDashboard
{"plan":"free","quota":100000,"used":318,"api_requests":1402}

Metering is metadata-only — request counts and token mints. The engine records no message content or participants.

Account API · password#

POST/v1/account/password/reset-requestPublic

Body {email}. Always returns 200 {"sent":true} (never reveals whether an email exists). In production this emails a link; in the demo build the reset_token is returned in the body.

POST/v1/account/password/resetPublic

Body {token, new_password} (8+ chars). 204 on success; 401 if the token is bad/expired.

POST/v1/account/password/changeDashboard

Body {old_password, new_password}. 204 on success; 403 if the current password is wrong.

Account API · meetings#

Server-persisted meetings for the dashboard — a meeting is metadata over a call room, shared across your org's devices and admins. All routes are dashboard-session authed (a native dealroom dash token or a Better Auth JWT — see Identity).

POST/v1/account/meetingsDashboard

Create/schedule a meeting. Body {title, start_at?} (start_at = unix seconds; 0/omitted = instant). Materializes the call room and binds it to your org. 201 with the meeting (incl. its room).

{"id":"mtg_…","org_id":"org_…","title":"Board call","room":"mtg-…","start_at":0,"created_at":…}
GET/v1/account/meetingsDashboard

List your org's meetings: {"meetings":[…]}.

DELETE/v1/account/meetings/{id}Dashboard

Cancel a meeting. 204; 404 if it isn't in your org.

POST/v1/account/meetings/{room}/tokenDashboard

Mint a room token for a meeting in your org, so the workspace can host the call straight from the signed-in session without handling a server-side API key. The room must belong to your org, else 403.

The token's subject is always your authenticated identity — it cannot be chosen by the caller. You get the host role (principal) only if you created the meeting or are an org owner/admin; everyone else receives a member token. Pass ?role=member to voluntarily take a member token.

{"token":"<room-jwt>","room":"mtg-…","member":"host","role":"principal"}

Recordings library

GET/v1/account/recordingsDashboard

Every recording across your org's meetings, newest first, with the meeting title joined and a presigned download_url for completed ones. Org-scoped by room ownership — another org's recordings never appear. Per-room start/stop/delete live under Recording.

{"recordings":[{"id":"rec_…","room":"mtg-…","title":"Board call","status":"completed","started_at":…,"download_url":"https://…"}]}

Identity, orgs & SSO#

Production B2B identity is served by a dedicated Better Auth service at https://auth.ollasync.com — email/password, magic-link, social, passkeys, TOTP 2FA, organizations and per-org SSO. The dealroom trusts the identity it issues; the server-blind media plane never touches it.

Base URL. Identity endpoints live under https://auth.ollasync.com/api/auth/… — a different host from the dealroom API above. Browser calls use a session cookie scoped to .ollasync.com (send credentials:"include"); cross-origin POSTs must carry an Origin in the service's trusted origins.

The bridge — how the dealroom trusts identity

The dashboard plane (Account API, Meetings) accepts either a native dealroom dash token or a Better Auth JWT. The integration flow:

  1. Sign the user in (any method below) → a session on auth.ollasync.com.
  2. Get a short-lived EdDSA JWT: GET /api/auth/token.
  3. Call the dealroom with Authorization: Bearer <that JWT>. The dealroom verifies it against the IdP's JWKS (GET /api/auth/jwks) and maps it to a dealroom org.
Org-level mapping. When the JWT carries an active organizationId, the whole team maps to one shared dealroom workspace (rooms, meetings, keys, recordings); a solo user with no active org is provisioned per-email. Inviting teammates into one Better Auth organization gives them one shared workspace.
GET/api/auth/tokenSession

Returns the short-lived EdDSA JWT the dealroom validates: {"token":"<jwt>"}. The payload carries id, email, name and — when an org is active — organizationId.

GET/api/auth/jwksPublic

The JWKS (EdDSA / Ed25519 public keys) the dealroom fetches to verify those JWTs. Rotated keys are picked up automatically.

Sign in

POST/api/auth/sign-up/emailPublic

Body {email, password, name}. Creates the user and a session.

POST/api/auth/sign-in/emailPublic

Body {email, password}.

POST/api/auth/sign-in/magic-linkPublic

Body {email, callbackURL}. Emails a one-time link; /api/auth/magic-link/verify completes it. needs SMTP

POST/api/auth/sign-in/socialPublic

Body {provider, callbackURL} — google (the only provider enabled). Returns {url} to redirect to. needs provider OAuth creds

POST/api/auth/sign-outSession

Ends the session.

Passkeys (WebAuthn)

Register and authenticate are two-step (options → browser WebAuthn → verify). rpID is the registrable parent so a passkey works across login. and auth.ollasync.com.

POST/api/auth/passkey/generate-register-optionsSession

Returns WebAuthn creation options; pass the new credential to /passkey/verify-registration.

POST/api/auth/passkey/verify-registrationSession

Stores the passkey.

POST/api/auth/passkey/generate-authenticate-optionsPublic

Options for passkey sign-in; pass the assertion to /passkey/verify-authentication.

POST/api/auth/passkey/verify-authenticationPublic

Completes passkey sign-in.

Also: GET /passkey/list-user-passkeys, POST /passkey/update-passkey, POST /passkey/delete-passkey.

Two-factor (TOTP)

POST/api/auth/two-factor/enableSession

Body {password}. Begins TOTP enrollment; fetch the URI with /two-factor/get-totp-uri, confirm with /two-factor/verify-totp {code}.

POST/api/auth/two-factor/verify-totpSession

Body {code}. Verifies a TOTP code.

POST/api/auth/two-factor/disableSession

Body {password}.

Also: /two-factor/get-totp-uri, /two-factor/generate-backup-codes, /two-factor/verify-backup-code, /two-factor/send-otp, /two-factor/verify-otp.

Organizations & teams

POST/api/auth/organization/createSession

Body {name, slug}. Creates an org and sets it active — its id then flows into the JWT (org-level mapping above).

GET/api/auth/organization/listSession

The caller's organizations.

POST/api/auth/organization/set-activeSession

Body {organizationId}. Sets the active org for the session (and the next JWT).

POST/api/auth/organization/invite-memberSession

Body {email, role, organizationId}. Returns an invitation id; the invitee accepts with /organization/accept-invitation {invitationId}. email needs SMTP

Also: /organization/accept-invitation, /organization/list-members, /organization/remove-member, /organization/update, /organization/delete.

Single sign-on (OIDC & SAML)

Register an identity provider per organization; members whose email domain matches are routed to it at sign-in.

POST/api/auth/sso/registerOrg owner/admin

Body {providerId, issuer, domain, organizationId, oidcConfig:{clientId, clientSecret, …}}. For a public IdP, pass oidcConfig.skipDiscovery:true with explicit authorizationEndpoint / tokenEndpoint / jwksEndpoint (copy them from your IdP's /.well-known/openid-configuration).

Discovery trust (anti-SSRF). Full server-side discovery requires the issuer origin to be allow-listed in the service's trustedOrigins. The self-serve flow uses explicit endpoints (skipDiscovery), which works for any public IdP without allow-listing.
POST/api/auth/sign-in/ssoPublic

Body {email | providerId | domain, callbackURL}. Returns {url} to redirect to the org's IdP. needs a configured IdP

Also: GET /sso/providers, /sso/get-provider, /sso/update-provider, /sso/delete-provider, OIDC callback /sso/callback/{providerId}, and SAML 2.0 endpoints under /sso/saml2/….

This covers the identity endpoints Ollasync exposes. For the exhaustive Better Auth API and client SDKs, see better-auth.com/docs.

Rooms · create#

POST/v1/roomsPublic · token optional

Create a room and become its principal. Pass a room token in Authorization to bind the room to your org (recommended); omit it for an un-owned demo room.

FieldType
room_idstringrequired
creator_idstringrequired
201 response
{"room_id":"deal-1","token":"<principal-room-jwt>"}

Rooms · key packages#

MLS key packages let a member be added to a group while offline. Each member publishes a pool; adding them consumes one (FIFO). Replenish when the count runs low.

POST/v1/keypackagesPublic

Publish one key package. Body {member_id, blob} (blob base64). 204.

POST/v1/keypackages/{member}/claimPublic

Consume one key package for {member}. 200 {"member_id","blob"}; 404 no key package available if the pool is empty.

GET/v1/keypackages/{member}/countPublic
{"member_id":"bob","count":7}

Rooms · members#

POST/v1/rooms/{room}/membersPrincipal

Add a member to the room roster. Body {member_id}. 204. (The cryptographic add — an MLS commit/welcome — is sent separately as messages.)

GET/v1/rooms/{room}/membersRoom token
{"members":["alice","bob"]}
DELETE/v1/rooms/{room}/members/{id}Principal

Remove a member. 204.

POST/v1/rooms/{room}/members/leaveRoom token

Leave the room yourself. 204.

Rooms · invites & the join flow#

For self-serve joins (no server-minted token), a guest redeems an invite and the principal approves. The guest picks a private auth_nonce; the server never logs it, and only the holder of the nonce can later redeem the issued token.

POST/v1/rooms/{room}/invitesPrincipal

Mint an invite token. Body (optional) {expires_unix, max_uses}.

{"token":"<invite>","expires_unix":0,"max_uses":0}
POST/v1/rooms/{room}/join-requestsPublic

Guest files a join request against an invite.

FieldType
tokenstringinvite token
member_idstringrequired
display_namestringoptional
key_packagestringbase64
auth_noncestringguest secret
{"id":"req_…","member_id":"carol","display_name":"Carol","status":"pending","requested_at":1718500000}
GET/v1/rooms/{room}/join-requestsPrincipal

List pending requests.

POST/v1/rooms/{room}/join-requests/{id}/approvePrincipal

Approve a request — mints (but does not release) the guest's room token.

POST/v1/rooms/{room}/join-requests/{id}/rejectPrincipal

Reject a request. 204.

POST/v1/auth/tokenPublic

Guest redeems their approved token by proving the nonce. One-time.

FieldType
room_idstring
request_idstring
auth_noncestring
{"token":"<room-jwt>","member_id":"carol","role":"member"}
POST/v1/auth/refreshRoom token

Rotate a room token before it expires (revokes the old jti). 200 {"token"}.

Messages#

POST/v1/rooms/{room}/messagesRoom token

Append an opaque blob to the room log. The server assigns a monotonic seq and fans it out to connected members. sender_id must equal your token's member.

FieldTypeNotes
sender_idstringoptionalDefaults to your token's sub
kindstringrequiredapplication · proposal · commit · welcome
epochintegeroptionalMLS epoch the blob belongs to
blobstringrequiredbase64 ciphertext (≤ ~8 MB)

Send Idempotency-Key: <uuid> to make retries safe — a repeat returns the original result instead of a duplicate.

201 response
{"seq":42,"room_id":"deal-1","sender_id":"alice","kind":"application","epoch":1,"blob":"<base64>"}
Send is not idempotent by default. Two identical POSTs (without an idempotency key) create two messages. Always set the header when retrying.
GET/v1/rooms/{room}/messagesRoom token

Fetch messages oldest-first. Returns a bare JSON array.

QueryTypeNotes
sinceintegerReturn seq > since (cursor)
member_idstringMust match your token; defaults to it
200 response
[
  {"seq":41,"room_id":"deal-1","sender_id":"bob","kind":"commit","epoch":1,"blob":"…"},
  {"seq":42,"room_id":"deal-1","sender_id":"alice","kind":"application","epoch":1,"blob":"…"}
]

Prefer the WebSocket for live delivery; poll ?since= as a fallback / for catch-up. A redacted message returns "redacted":true with an empty blob.

DELETE/v1/rooms/{room}/messages/{seq}Room token

Redact (tombstone) a message you sent — the blob is cleared, the seq remains.

POST/v1/rooms/{room}/expirePrincipal

Bulk-expire old messages (disappearing-message policy).

Reactions, threads & edits#

These sit beside the blind log: the server stores only the relations (and opaque edit ciphertext), never message meaning. All three also fan out over the WebSocket (reaction / edit events) and appear inline on message fetch (parent_seq, edited).

POST/v1/rooms/{room}/messages/{seq}/reactionsRoom token

React to a message. Body {emoji} (≤32 bytes) or {remove:true}.

GET/v1/rooms/{room}/messages/{seq}/reactionsRoom token
{"seq":42,"reactions":{"alice":"👍","bob":"🎉"},"counts":{"👍":1,"🎉":1}}
POST/v1/rooms/{room}/messagesRoom token

Thread reply: send a normal message with {…, "parent_seq": 42}. The reply then carries parent_seq on fetch.

GET/v1/rooms/{room}/messages/{seq}/repliesRoom token
{"seq":42,"replies":[43,47,51]}
POST/v1/rooms/{room}/messages/{seq}/editRoom token

Replace a message's ciphertext (author only). Body {blob} (base64). Fetch then returns the new blob with "edited":true. 403 if not the author.

Pinned messages#

POST/v1/rooms/{room}/pinsRoom token

Pin a message. Body {seq}. Broadcasts a pin event.

GET/v1/rooms/{room}/pinsRoom token
{"pins":[{"seq":42,"by":"alice","at":1718500000}]}
DELETE/v1/rooms/{room}/pins/{seq}Room token

Unpin. 204.

Polls#

A structured message. The server tallies votes by option index and never needs the text (send it opaque if you like). One vote per member (replaceable); multi allows several.

POST/v1/rooms/{room}/pollsRoom token

Body {question, options:[…], multi?} (2–20 options).

{"id":"poll_…","question":"Approve the deal?","options":["Yes","No","Abstain"],"multi":false,"closed":false}
POST/v1/rooms/{room}/polls/{id}/voteRoom token

Body {options:[index,…]}. 409 if the poll is closed.

GET/v1/rooms/{room}/polls/{id}Room token
{"id":"poll_…","options":["Yes","No","Abstain"],"counts":[5,2,1],"total":8,"your_vote":[0]}
GET/v1/rooms/{room}/pollsRoom token

List the room's polls.

POST/v1/rooms/{room}/polls/{id}/closePrincipal

Close voting. 204.

Read-only / announcement rooms#

PATCH/v1/rooms/{room}/settingsPrincipal

Toggle room settings (any subset):

FieldTypeEffect
read_onlyboolOnly the principal may post (announcements)
auto_recordboolStart a recording automatically when a meeting begins
message_ttlint (s)Disappearing messages — auto-expire older than this (0 = keep)
GET/v1/rooms/{room}/settingsRoom token
{"read_only":true,"auto_record":false,"message_ttl":86400}

Scheduled messages#

Hand the server an (already-encrypted) blob to release into the room at a future time. The server stays blind — it holds opaque ciphertext and appends it via the normal path at send_at.

POST/v1/rooms/{room}/messages/scheduleRoom token

Body {kind, epoch, blob, send_at} (send_at = future unix seconds). Returns {id, send_at}.

GET/v1/rooms/{room}/scheduledRoom token

Pending scheduled messages (blob omitted).

DELETE/v1/rooms/{room}/scheduled/{id}Room token

Cancel (author or principal). 204.

Receipts & typing#

POST/v1/rooms/{room}/messages/readRoom token

Mark read up to a sequence. Body {seq}. Broadcasts a receipt event to the room.

GET/v1/rooms/{room}/receiptsRoom token

Per-member read positions.

POST/v1/rooms/{room}/typingRoom token

Body {state} = start | stop. Broadcasts a typing event (ephemeral, not stored).

Presence#

PUT/v1/rooms/{room}/presenceRoom token

Set your state. Body {state} = online | away | dnd.

GET/v1/rooms/{room}/presenceRoom token

Current presence for the room.

Connecting the WebSocket also marks you online and emits presence to others automatically.

Realtime (WebSocket)#

WS/v1/rooms/{room}/connect?token=<room-jwt>Room token

Upgrade to a WebSocket for push delivery of messages, presence, typing, receipts and call signaling. The token may be passed as the ?token= query param (browsers can't set headers on WS) or as a normal bearer header. You must be a member of the room; org isolation applies.

The server sends a hello on connect, then streams events. Every frame is a JSON wsEvent:

server → client events
{"type":"hello","online":["alice","bob"]}
{"type":"message","message":{"seq":43,"sender_id":"bob","kind":"application","epoch":1,"blob":"…"}}
{"type":"presence","member_id":"bob","state":"online"}
{"type":"typing","member_id":"bob","state":"start"}
{"type":"receipt","member_id":"bob","seq":43}
{"type":"call", "call":{ /* call signaling */ }}
{"type":"ping"}
typeMeaning
helloSent once on connect; online[] lists connected members
messageA new message blob (mirror of the REST shape)
presence / typingMember state changes
receiptMember read up to seq
callRing/accept/decline/etc. (see Calls)
ping / pongKeepalive — reply to ping with pong
browser client
const ws = new WebSocket(`${location.origin.replace('http','ws')}/v1/rooms/deal-1/connect?token=${roomJwt}`)
ws.onmessage = (e) => {
  const ev = JSON.parse(e.data)
  if (ev.type === 'message') decryptAndRender(ev.message)   // MLS decrypt client-side
  if (ev.type === 'ping')    ws.send(JSON.stringify({type:'pong'}))
}

Sending messages still goes over POST …/messages (durable, idempotent). The socket is for delivery + presence.

Attachments#

Large files travel out-of-band via signed URLs; you encrypt the bytes client-side first. The capability is the signed URL, not the JWT.

POST/v1/rooms/{room}/attachmentsRoom token

Request upload + download URLs. Body {size, content_type}.

{"attachment_id":"att_…","upload_url":"…","download_url":"…","expires_unix":1718600000,"max_size":26214400}
PUT/v1/attachments/{id}Signed URL

Upload the encrypted bytes to the signed upload_url (raw body, not JSON).

GET/v1/attachments/{id}Signed URL

Download the encrypted bytes; decrypt client-side.

Deal-room · deals#

The workflow layer wraps a room with deal metadata, parties/roles, an NDA gate and a role-gated document vault. A deal is a room — every room route above also applies to a deal id.

POST/v1/dealsPublic · token optional

Create a deal (room + metadata); the creator becomes admin/principal. Pass an org token to bind the tenant.

FieldType
namestringrequired
typestringe.g. "M&A"
creator_idstringrequired
target_closeintegerunix date
{"deal_id":"deal-7a1c…","token":"<principal-room-jwt>","role":"admin"}
GET/v1/deals/{room}Room token

Deal metadata + your_role within it.

PATCH/v1/deals/{room}Principal

Update {stage, target_close}.

Deal-room · parties & roles#

POST/v1/deals/{room}/partiesPrincipal

Add a counterparty org. Body {org_name, role} (e.g. buyer, seller, advisor).

GET/v1/deals/{room}/partiesRoom token
{"parties":[{"id":"pty_…","org_name":"Buyer LLC","role":"buyer"}]}
POST/v1/deals/{room}/membersPrincipal

Assign a room member to a party + role. Body {member_id, party_id, role}. Role gates document-folder access.

GET/v1/deals/{room}/membersRoom token

List deal members with their party + role.

Deal-room · NDA gating#

When an NDA is set, a member sees no documents until they accept it. Folder/document listings return nda_required: true (or 403 accept the NDA first) until then.

POST/v1/deals/{room}/ndaPrincipal

Set/replace the NDA text. Body {text}.

GET/v1/deals/{room}/ndaRoom token
{"required":true,"text":"This Non-Disclosure Agreement…","accepted":false}
POST/v1/deals/{room}/nda/acceptRoom token

Record the caller's acceptance (audited). Unlocks the vault for them.

Deal-room · document vault#

Folders carry an access list of roles; documents inside are visible only to members whose deal-role is allowed (and who passed the NDA). Document bytes are E2E-encrypted client-side — the server stores ciphertext and serves signed URLs.

POST/v1/deals/{room}/foldersPrincipal

Create a folder. Body {name, parent_id?, access:[roles]}.

GET/v1/deals/{room}/foldersRoom token
{"folders":[{"id":"fld_…","name":"Financials","access":["buyer","advisor"]}],"nda_required":false}
POST/v1/deals/{room}/folders/{fid}/documentsRoom token

Create document metadata + a signed upload URL for its encrypted bytes. Body {name, size, content_type}. Caller must have folder access. The deal's members are emailed — and the access trail records the upload — only when the bytes actually land on the upload URL; uploaded_at stays 0 until then.

{"doc_id":"…","name":"P&L.pdf.enc","size":18234,"content_type":"application/octet-stream","uploaded_at":0,"upload_url":"…"}
GET/v1/deals/{room}/folders/{fid}/documentsRoom token

List documents in a folder (access-gated). 403 until the NDA is accepted.

GET/v1/deals/{room}/documents/{docid}/downloadRoom token

Gated signed download URL; the access is recorded in the trail.

{"download_url":"…","name":"P&L.pdf.enc","content_type":"application/octet-stream"}
GET/v1/deals/{room}/activityPrincipal

The data-room access trail — who viewed/downloaded what, when.

Calls#

Voice/video runs over an SFU with E2EE media; the API issues per-room call tokens and relays ring/answer signaling. Media keys derive from the room's MLS exported secret — the SFU forwards encrypted frames only.

POST/v1/rooms/{room}/call-tokenRoom token
{"url":"wss://sfu…","token":"<livekit-jwt>","region":"eu","room":"call-deal-1"}
GET/v1/rooms/{room}/callRoom token
{"active":true,"call_id":"call_…","room":"deal-1","started_by":"alice","started_at":1718500000}
POST/v1/rooms/{room}/call/ringRoom token

Start/ring a call (notifies members over the socket).

Signaling lifecycle, all room-token: POST …/call/{callId}/accept · /decline · /cancel · /leave · and principal-only /end.

GET/v1/rooms/{room}/callsRoom token

Call history (metadata only) — who started, when, who joined, ended.

{"calls":[{"id":"…","started_by":"alice","started_at":…,"ended_at":…,"participants":["alice","bob"]}]}

Region pinning: pass ?region=eu-de to call-token; set LIVEKIT_URL_<REGION> to route that region to a jurisdiction-specific SFU.

Retired webinar API

Webinar hosting, audience entry, stage controls and OBS publishing are permanently retired. Dedicated webinar endpoints return 404. Creating a meeting with webinar:true is rejected.

For older meeting clients only, authenticated GET /v1/rooms/{room}/webinar returns inactive state. It never creates media or issues a token. New clients should not call it. Historical records remain protected.

Recording#

Server-side recording via self-hosted LiveKit Egress → S3/MinIO (all open source, no paid services). The principal starts/stops; the backend tracks status and hands you a time-limited presigned download URL for the finished MP4.

Recording is a disclosed E2EE relaxation. The recorder must see decodable media, so it joins as a visible participant — a WS recording event and a recording.started webhook fire so every client can show the indicator. Record sessions without app-layer E2EE (the SFU/recorder can't decode insertable-stream ciphertext). Don't record if your threat model forbids any server-side plaintext.
POST/v1/rooms/{room}/recording/startPrincipal

Begin recording the meeting. Body {mode} accepts call (default) only. Retired webinar mode returns 410. 503 if the SFU/Egress isn't configured.

{"id":"rec_…","room":"deal-1","egress_id":"EG_…","status":"active","file_key":"rec/call-deal-1-….mp4","started_by":"alice","started_at":…}
POST/v1/rooms/{room}/recording/stopPrincipal

Body {recording_id}. Stops the egress; the file finalizes + uploads asynchronously.

GET/v1/rooms/{room}/recordingsRoom token

List recordings with live status (active · stopping · completed · failed); completed ones include a presigned download_url (valid 1h).

{"recordings":[{"id":"rec_…","status":"completed","file_key":"rec/…","download_url":"https://…?X-Amz-Signature=…"}]}
DELETE/v1/rooms/{room}/recordings/{id}Principal

Delete a recording + its S3 object. 204.

Options: recording/start accepts {audio:true} for audio-only (cheaper). Auto-record: PATCH …/settings {auto_record:true} starts a recording automatically when a meeting begins. Retention: set REC_RETENTION_DAYS to auto-delete old recordings.

Webhooks: recording.started / recording.completed / recording.failed. Deployment env: LIVEKIT_HTTP_URL, REC_S3_ENDPOINT, REC_S3_BUCKET, REC_S3_ACCESS_KEY, REC_S3_SECRET.

Moderation#

POST/v1/rooms/{room}/blockRoom token

Block (or unblock) another member from messaging you. Body {target, unblock?}. A blocked sender gets 403.

POST/v1/rooms/{room}/reportRoom token

Report a member/message. Body {target, seq?, reason} → {id}. You cannot report yourself (400).

GET/v1/rooms/{room}/reportsPrincipal

List abuse reports for the room.

Principal escalation, all principal-only: POST …/moderation/{member}/suspend (mute — the member's sends return 403) · /unsuspend · /ban (blocks re-join). Full action history: GET /v1/rooms/{room}/audit.

Devices & push#

POST/v1/devicesRoom token

Register a push device. Body {device_id, push_token}. Wake notifications carry no plaintext — just a nudge to sync.

DELETE/v1/devices/{deviceID}Room token

Unregister a device.

Idempotency#

Send an Idempotency-Key: <unique> header on create/mint POSTs to make retries safe — a repeat with the same key replays the original result (with Idempotent-Replay: true) instead of acting twice. Supported on:

  • POST /v1/rooms · POST /v1/deals (creation)
  • POST /v1/account/keys · POST /v1/account/token · /token/bulk
  • POST /v1/rooms/{room}/messages (message send — see Messages)

OpenAPI spec#

GET/v1/openapi.jsonPublic

A machine-readable OpenAPI 3.0 description of the core surface (account, rooms, messaging, deals) with bearerAuth + apiKeyAuth security schemes. Import it into Postman/Insomnia or a client generator.

curl __BASE__/v1/openapi.json | npx @openapitools/openapi-generator-cli generate -g typescript-fetch -i - -o ./sdk

Errors#

Errors are JSON: {"error":"human-readable message"} with a matching HTTP status.

200/201
Success. 204 for actions with no body.
400
Invalid request — bad JSON, unknown fields, malformed id/blob, self-report.
401
Missing / invalid / expired / revoked token or API key.
403
Not a member · wrong role · blocked/suspended · wrong org (token not valid for this org) · room ownership conflict.
404
Room / key package / join request / message / call not found.
409
Conflict — email already registered, room exists, room closed.
429
Rate limited. Honor Retry-After.
500
Server error. Safe to retry idempotent calls.

Rate limits#

Per-IP token buckets, plus a per-identity ceiling across all authenticated routes. On exhaustion you get 429 with Retry-After: 1.

BucketSustainedBurstApplies to
create1 / s5Room / deal creation
auth3 / s10Token exchange, refresh, account login/signup/reset
join2 / s6Join requests
key5 / s10Key-package publish / claim
send10 / s20Message append
token30 / s60Per-identity ceiling on every authed route

WebSocket connections are additionally capped per client IP.

Rate-limited responses carry X-RateLimit-Limit (the bucket's burst) and X-RateLimit-Remaining (tokens left); a 429 also sets Retry-After.

SDKs#

Typed clients are generated from this spec — TypeScript, Python, and Go, plus a Postman collection. Regenerate with sdk/generate.sh, or import /v1/openapi.json directly into Postman / Insomnia / your favourite generator.

Coverage: the entire spec is generated from the code — paths and methods from the live route table, per-endpoint security from the auth wrapper recorded per route (the route's actual auth: public / bearer / X-API-Key, not a guess), and request/response body schemas by reflecting the Go structs (119 component schemas; 76 typed responses + 44 typed request bodies). So SDK methods return real types — signup → SessionResp, GET messages → MessageResp[], me → MeResp — verified by a real typed round-trip against the live server. Paths + security are derived from the route table and can't drift (full coverage). Body schemas match their Go struct by reflection, but the route→type/code mapping is hand-asserted — so a handler-match test drives real requests and asserts the live status + body-presence agree with the spec (it caught 5 wrong entries on its first run). Caveats: generated fields are all optional, enums are bare strings, timestamps are integers. The remaining endpoints are bodyless (204) or return ad-hoc/inline shapes; they still carry accurate path + security.
// TypeScript (typescript-fetch)
import { Configuration, DefaultApi } from "./sdk/ts"
const api = new DefaultApi(new Configuration({ basePath: "__BASE__", accessToken: DASH_JWT }))

Voice messages need no special endpoint — send an audio file through attachments (content_type: audio/*) and reference it from a normal message.

Limitations & posture#

Read this before trusting any "blind/E2E" claim. This is a working test deployment, not a hardened production service.
  • "Blind" = blind to message body ciphertext. The server necessarily sees metadata — sender id, sequence, kind, room, timestamps — and routes on it.
  • Recording disables E2E. Server-side recording requires the recorder to see decodable media; for a recorded session the SFU + egress handle plaintext. It's disclosed (a recording event + webhook), but it is a real relaxation. E2E-preserving recording would have to be client-side.
  • Reactions and poll text are stored as the client sends them. The server tallies poll votes by index and treats emoji/option text as opaque, but if a client sends them in cleartext the server can see them. Encrypt them client-side if that matters.
  • Single-node. One delivery service, one SFU, one egress node — so one recording at a time. No horizontal scaling yet.
  • Ephemeral state (lost on restart): webhook delivery log, idempotency caches, login-lockout counters, rate-limiter, call-history dedup. Rooms, messages, accounts, keys, webhooks, deals, recordings metadata are Postgres-durable.
  • Test deployment. Runs via go run with development secrets and self-signed certs. Not production-secured.

Building a client#

The server is transport. Your client owns the crypto. To interoperate, a client must:

  • Run a conformant MLS (RFC 9420) stack — any library, any language. A common choice for web is Wire core-crypto (WASM); native clients can use any RFC-9420 implementation.
  • Publish key packages so others can add you offline.
  • Create/advance the MLS group locally; ship commit/welcome/proposal blobs as messages. Relay the commit to existing members (not just the welcome to the new one) or epochs desync.
  • Encrypt application payloads and attachments to opaque blobs; the server stores them verbatim.
  • Hold a WebSocket for delivery; fall back to GET …/messages?since= for catch-up.
No vendor lock-in. Nothing here requires a specific SDK or language — only the open MLS wire format. The Rust/WASM core is an optional convenience, not a requirement.

← Back to the dashboard to create an API key and start minting tokens.