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.
- Authenticates tenants & members (JWT / API keys)
- Stores & orders opaque message blobs per room
- Fans out delivery (poll or WebSocket)
- Meters usage, enforces isolation & moderation
- 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 attachmentPUT/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(bearerDEALROOM_METRICS_TOKEN, required —401until it is set; never public).
Authentication#
Every non-public call carries a bearer credential. There are three kinds, each for a different actor:
| Credential | Header | Who holds it | Scopes |
|---|---|---|---|
| Dashboard session | Authorization: Bearer <jwt> | Your team (dashboard login) | Account API only (typ=dash) |
| API key | X-API-Key: sk_live_…or Authorization: Bearer sk_live_… | Your backend (server-to-server) | Mint room tokens; metered per org |
| Room token | Authorization: Bearer <jwt> | One end-user, one room | That 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.
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:
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 gets403 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
orgclaim 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.
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]"}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"}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)curl -X POST __BASE__/v1/rooms \
-H "authorization: Bearer <room-jwt>" \
-H "content-type: application/json" \
-d '{"room_id":"deal-1","creator_id":"alice"}'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>"}'blob is opaque MLS ciphertext your client produced. The server never decrypts it. See Building a client.Account API · sign up & log in#
Create an organization and its first (owner) user; returns a dashboard session token.
| Field | Type | Notes | |
|---|---|---|---|
| org_name | string | required | Display name |
| string | required | Unique; 409 if taken | |
| password | string | required | 8+ chars |
{"token":"<dash-jwt>","org_id":"org_3f9a1c","email":"[email protected]"}Exchange {email, password} for a dashboard session token. 401 on bad credentials.
{"token":"<dash-jwt>","org_id":"org_3f9a1c","email":"[email protected]"}{"user_id":"usr_…","email":"[email protected]","org_id":"org_…","org_name":"Acme Capital","plan":"free","role":"owner"}Revokes the current dashboard session (its jti). Returns 204.
Account API · API keys#
Mint a new sk_live_ API key. The full secret is returned once — store it now; only its prefix + hash are kept.
| Field | Type | Notes | |
|---|---|---|---|
| name | string | optional | Label |
| scopes | string[] | optional | Scopes: token:mint, account:read. Empty = full access. A key missing token:mint gets 403 on the token endpoints. |
{"key":"sk_live_c48758…","key_id":"key_a1b2c3","name":"production","prefix":"sk_live_c48758","scopes":["token:mint"]}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}]}Revoke a key immediately. 204 on success, 404 if it isn't yours.
Account API · mint a room token#
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.
| Field | Type | Notes | |
|---|---|---|---|
| room | string | required | Room id |
| member | string | required | Your user's id (token's sub) |
| role | string | optional | member (default) or principal |
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}Mint room tokens for many members of one room in a single call (seed a group). Requires token:mint. Supports Idempotency-Key.
{"room":"deal-1","members":[{"member":"alice","role":"principal"},{"member":"bob"},{"member":"carol"}]}{"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).
Create an endpoint. The signing secret is returned once. The URL must be public HTTPS (loopback/private rejected, SSRF guard).
{"url":"https://api.acme.com/hooks/ollasync","events":["message.created"]}
→ {"id":"wh_…","url":"…","secret":"whsec_…","events":["message.created"],"created_at":…}List endpoints (secret never returned).
Remove an endpoint. 204.
Fire a signed ping sample event to the endpoint (verify your HMAC check). 202.
Recent delivery outcomes (last 50): event, HTTP code, ok, error, timestamp.
{"deliveries":[{"event":"message.created","code":200,"ok":true,"at":1718500000}]}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.
{"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.
Owner/admin only. Body {email, role}. Returns an invite_token (emailed in production).
Invitee accepts. Body {token, password} → a dashboard session for the new member.
List org members (id, email, role).
Owner only; removes a teammate. 204.
Hardening
Dashboard action trail (key/webhook/team/login/origin changes).
{"events":[{"actor":"usr_…","action":"key_create","target":"key_…","at":1718500000}]}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.
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#
{"org_id":"org_…","usage":{"api_requests":1402,"tokens_minted":318}}{"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#
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.
Body {token, new_password} (8+ chars). 204 on success; 401 if the token is bad/expired.
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).
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":…}List your org's meetings: {"meetings":[…]}.
Cancel a meeting. 204; 404 if it isn't in your org.
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
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.
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:
- Sign the user in (any method below) → a session on
auth.ollasync.com. - Get a short-lived EdDSA JWT:
GET /api/auth/token. - 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.
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.Returns the short-lived EdDSA JWT the dealroom validates: {"token":"<jwt>"}. The payload carries id, email, name and — when an org is active — organizationId.
The JWKS (EdDSA / Ed25519 public keys) the dealroom fetches to verify those JWTs. Rotated keys are picked up automatically.
Sign in
Body {email, password, name}. Creates the user and a session.
Body {email, password}.
Body {email, callbackURL}. Emails a one-time link; /api/auth/magic-link/verify completes it. needs SMTP
Body {provider, callbackURL} — google (the only provider enabled). Returns {url} to redirect to. needs provider OAuth creds
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.
Returns WebAuthn creation options; pass the new credential to /passkey/verify-registration.
Stores the passkey.
Options for passkey sign-in; pass the assertion to /passkey/verify-authentication.
Completes passkey sign-in.
Also: GET /passkey/list-user-passkeys, POST /passkey/update-passkey, POST /passkey/delete-passkey.
Two-factor (TOTP)
Body {password}. Begins TOTP enrollment; fetch the URI with /two-factor/get-totp-uri, confirm with /two-factor/verify-totp {code}.
Body {code}. Verifies a TOTP code.
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
Body {name, slug}. Creates an org and sets it active — its id then flows into the JWT (org-level mapping above).
The caller's organizations.
Body {organizationId}. Sets the active org for the session (and the next JWT).
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.
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).
trustedOrigins. The self-serve flow uses explicit endpoints (skipDiscovery), which works for any public IdP without allow-listing.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#
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.
| Field | Type | |
|---|---|---|
| room_id | string | required |
| creator_id | string | required |
{"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.
Publish one key package. Body {member_id, blob} (blob base64). 204.
Consume one key package for {member}. 200 {"member_id","blob"}; 404 no key package available if the pool is empty.
{"member_id":"bob","count":7}Rooms · members#
Add a member to the room roster. Body {member_id}. 204. (The cryptographic add — an MLS commit/welcome — is sent separately as messages.)
{"members":["alice","bob"]}Remove a member. 204.
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.
Mint an invite token. Body (optional) {expires_unix, max_uses}.
{"token":"<invite>","expires_unix":0,"max_uses":0}Guest files a join request against an invite.
| Field | Type | |
|---|---|---|
| token | string | invite token |
| member_id | string | required |
| display_name | string | optional |
| key_package | string | base64 |
| auth_nonce | string | guest secret |
{"id":"req_…","member_id":"carol","display_name":"Carol","status":"pending","requested_at":1718500000}List pending requests.
Approve a request — mints (but does not release) the guest's room token.
Reject a request. 204.
Guest redeems their approved token by proving the nonce. One-time.
| Field | Type |
|---|---|
| room_id | string |
| request_id | string |
| auth_nonce | string |
{"token":"<room-jwt>","member_id":"carol","role":"member"}Rotate a room token before it expires (revokes the old jti). 200 {"token"}.
Messages#
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.
| Field | Type | Notes | |
|---|---|---|---|
| sender_id | string | optional | Defaults to your token's sub |
| kind | string | required | application · proposal · commit · welcome |
| epoch | integer | optional | MLS epoch the blob belongs to |
| blob | string | required | base64 ciphertext (≤ ~8 MB) |
Send Idempotency-Key: <uuid> to make retries safe — a repeat returns the original result instead of a duplicate.
{"seq":42,"room_id":"deal-1","sender_id":"alice","kind":"application","epoch":1,"blob":"<base64>"}Fetch messages oldest-first. Returns a bare JSON array.
| Query | Type | Notes |
|---|---|---|
| since | integer | Return seq > since (cursor) |
| member_id | string | Must match your token; defaults to it |
[
{"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.
Redact (tombstone) a message you sent — the blob is cleared, the seq remains.
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).
React to a message. Body {emoji} (≤32 bytes) or {remove:true}.
{"seq":42,"reactions":{"alice":"👍","bob":"🎉"},"counts":{"👍":1,"🎉":1}}Thread reply: send a normal message with {…, "parent_seq": 42}. The reply then carries parent_seq on fetch.
{"seq":42,"replies":[43,47,51]}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#
Pin a message. Body {seq}. Broadcasts a pin event.
{"pins":[{"seq":42,"by":"alice","at":1718500000}]}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.
Body {question, options:[…], multi?} (2–20 options).
{"id":"poll_…","question":"Approve the deal?","options":["Yes","No","Abstain"],"multi":false,"closed":false}Body {options:[index,…]}. 409 if the poll is closed.
{"id":"poll_…","options":["Yes","No","Abstain"],"counts":[5,2,1],"total":8,"your_vote":[0]}List the room's polls.
Close voting. 204.
Read-only / announcement rooms#
Toggle room settings (any subset):
| Field | Type | Effect |
|---|---|---|
| read_only | bool | Only the principal may post (announcements) |
| auto_record | bool | Start a recording automatically when a meeting begins |
| message_ttl | int (s) | Disappearing messages — auto-expire older than this (0 = keep) |
{"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.
Body {kind, epoch, blob, send_at} (send_at = future unix seconds). Returns {id, send_at}.
Pending scheduled messages (blob omitted).
Cancel (author or principal). 204.
Receipts & typing#
Mark read up to a sequence. Body {seq}. Broadcasts a receipt event to the room.
Per-member read positions.
Body {state} = start | stop. Broadcasts a typing event (ephemeral, not stored).
Presence#
Set your state. Body {state} = online | away | dnd.
Current presence for the room.
Connecting the WebSocket also marks you online and emits presence to others automatically.
Realtime (WebSocket)#
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:
{"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"}| type | Meaning |
|---|---|
| hello | Sent once on connect; online[] lists connected members |
| message | A new message blob (mirror of the REST shape) |
| presence / typing | Member state changes |
| receipt | Member read up to seq |
| call | Ring/accept/decline/etc. (see Calls) |
| ping / pong | Keepalive — reply to ping with pong |
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.
Request upload + download URLs. Body {size, content_type}.
{"attachment_id":"att_…","upload_url":"…","download_url":"…","expires_unix":1718600000,"max_size":26214400}Upload the encrypted bytes to the signed upload_url (raw body, not JSON).
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.
Create a deal (room + metadata); the creator becomes admin/principal. Pass an org token to bind the tenant.
| Field | Type | |
|---|---|---|
| name | string | required |
| type | string | e.g. "M&A" |
| creator_id | string | required |
| target_close | integer | unix date |
{"deal_id":"deal-7a1c…","token":"<principal-room-jwt>","role":"admin"}Deal metadata + your_role within it.
Update {stage, target_close}.
Deal-room · parties & roles#
Add a counterparty org. Body {org_name, role} (e.g. buyer, seller, advisor).
{"parties":[{"id":"pty_…","org_name":"Buyer LLC","role":"buyer"}]}Assign a room member to a party + role. Body {member_id, party_id, role}. Role gates document-folder access.
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.
Set/replace the NDA text. Body {text}.
{"required":true,"text":"This Non-Disclosure Agreement…","accepted":false}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.
Create a folder. Body {name, parent_id?, access:[roles]}.
{"folders":[{"id":"fld_…","name":"Financials","access":["buyer","advisor"]}],"nda_required":false}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":"…"}List documents in a folder (access-gated). 403 until the NDA is accepted.
Gated signed download URL; the access is recorded in the trail.
{"download_url":"…","name":"P&L.pdf.enc","content_type":"application/octet-stream"}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.
{"url":"wss://sfu…","token":"<livekit-jwt>","region":"eu","room":"call-deal-1"}{"active":true,"call_id":"call_…","room":"deal-1","started_by":"alice","started_at":1718500000}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.
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 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.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":…}Body {recording_id}. Stops the egress; the file finalizes + uploads asynchronously.
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 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#
Block (or unblock) another member from messaging you. Body {target, unblock?}. A blocked sender gets 403.
Report a member/message. Body {target, seq?, reason} → {id}. You cannot report yourself (400).
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#
Register a push device. Body {device_id, push_token}. Wake notifications carry no plaintext — just a nudge to sync.
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/bulkPOST /v1/rooms/{room}/messages(message send — see Messages)
OpenAPI spec#
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.
204 for actions with no body.token not valid for this org) · room ownership conflict.Retry-After.Rate limits#
Per-IP token buckets, plus a per-identity ceiling across all authenticated routes. On exhaustion you get 429 with Retry-After: 1.
| Bucket | Sustained | Burst | Applies to |
|---|---|---|---|
| create | 1 / s | 5 | Room / deal creation |
| auth | 3 / s | 10 | Token exchange, refresh, account login/signup/reset |
| join | 2 / s | 6 | Join requests |
| key | 5 / s | 10 | Key-package publish / claim |
| send | 10 / s | 20 | Message append |
| token | 30 / s | 60 | Per-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.
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#
- "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
recordingevent + 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 runwith 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/proposalblobs 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.
← Back to the dashboard to create an API key and start minting tokens.