Devices
Manage account devices, run shared recorder sessions, and connect a certificate-authenticated device.
Optima devices use two separate interfaces. Your app manages devices with an account session, and the device firmware talks to Optima with its own client certificate.
| Interface | Paths | Authentication | Request and response bodies |
|---|---|---|---|
| Account | /devices, /devices/claims, /devices/sessions, /shared-recorders | Bearer session | JSON |
| Hardware | /device/me, /device/claims/redeem, /device/session/end, /device/streams, /device/chunks/*, /device/activity | Verified mTLS client certificate | JSON or CBOR, plus raw bytes for audio uploads |
A bearer token cannot authenticate a hardware request, and a device certificate does not replace an account session. A certificate-authenticated /device request without a verified certificate returns 401 UNAUTHENTICATED. Send X-API-Version: 1 on both interfaces.
Manage account devices
| Operation | Request |
|---|---|
| List devices | GET /devices returns a { items, cursor } page |
| Read a device | GET /devices/{id} |
| Rename a device source | PATCH /sources/{source_id} with { "name": "Desk device" }, or null to clear the name |
| Remove a device | DELETE /devices/{id} returns 204 |
A device's source owns its account-facing name. A name is trimmed and must contain 1–80 characters. Renaming the source preserves the hardware binding and audio history.
| Field | Description |
|---|---|
id | The device's permanent device ID |
binding_kind | owner. Shared recorders are never account devices. |
session_id | Always null |
source | The device's recording source, including its id, kind (device), name, and created_at |
bound_at | When the device was first bound to an account, or null |
last_online_at | When the device last made a certificate-verified request, or null |
firmware_version, hardware_id, and other firmware fields | Reported by the device. Any of them can be null. |
Use source.id to filter turns and to read source activity. The device ID identifies the hardware; the source ID identifies its audio and owns its friendly name.
See list devices, get a device, rename a source, and remove a device for full schemas.
Remove a device
DELETE /devices/{id} removes a device from the account. Its recording source is deleted, so the device's turns and audio disappear from account reads, and the device's binding and open claims are cleared. It returns 404 when the account does not have a bound device with that ID, including after a previous removal.
The device does not need to be connected. On its next request Optima answers 403 DEVICE_UNBOUND, and the device erases its local account data and returns to setup. Any account can then bind it again, which creates a new binding and a new source.
Bind a device to an account
Binding connects a device to one account. While bound, a device cannot be claimed by another account; remove it first.
-
Create a claim. The account client calls
POST /devices/claimswith the device ID:{ "device_id": "<device_id>" }The 201 response contains
claim_tokenandexpires_at. A claim expires after 10 minutes. A device can have only one open claim; creating another returns 409. -
Hand over the token. Pass
claim_tokento that device through your trusted setup channel. Treat it as a secret. -
Redeem the claim. The device calls
POST /device/claims/redeemwith its client certificate:{ "claim_token": "<claim_token>" }The response contains exactly
owner_user_id,device_id, andbinding_epoch. A shared recorder cannot redeem an owner claim.
Only the device whose certificate matches the claimed device ID can redeem the claim. An unknown or expired claim returns 404. Until the claim expires, redeeming it again returns the same binding, so a lost response is safe to retry.
The device keeps binding_epoch and sends it with stream, stream-end, chunk-intent, and activity requests. A request whose epoch does not match the current binding returns 409 BINDING_EPOCH_MISMATCH. An unbound device can redeem a claim but receives 403 DEVICE_UNBOUND from stream, upload, and activity routes.
Check the device's account
GET /device/me returns the device's current owner_user_id, device_id, and binding_epoch. A shared recorder in a session also receives binding_kind and session_expires_at. Devices call it after connecting to a network and periodically, and compare the reply with their stored binding.
| Reply | Meaning | Device behavior |
|---|---|---|
| Same owner and epoch | Still linked | Continue recording and uploading. |
| Different owner or epoch | Claimed again | Erase local account data and return to setup. |
403 DEVICE_UNBOUND | Removed from its account, or a shared recorder's session ended | Same as above. A shared recorder keeps its saved network settings. |
409 BINDING_EPOCH_MISMATCH | The request used a replaced binding | Same as above. |
403 ACCOUNT_DELETED | The owning account is deleted. If it is restored within 7 days, the binding works again; otherwise its data is erased and the device is then released | Pause recording and uploads and check again later. Once the reply is DEVICE_UNBOUND, erase local account data and return to setup. |
Upload and activity routes return the same codes. Treat only these codes with these statuses as statements about the binding; any other error is an ordinary failure.
Wait for a change
A device can keep one GET /device/me request open and learn about a change within about a second, instead of asking every few seconds. Every binding reply and every 403 DEVICE_UNBOUND reply carries the response header X-A5-Binding-State: a short opaque token that changes whenever the binding the device must act on changes. Compare tokens only for equality.
| State | Token changes when |
|---|---|
| No binding | The device is bound or a session starts |
| Owner binding | The binding is replaced or removed |
| Session binding | The session is replaced, extended, ended by its host, or over |
To wait, send the last token back with two query parameters:
| Parameter | Value |
|---|---|
known | The X-A5-Binding-State value of the last reply, 1 to 128 characters |
wait | How long Optima may hold the request, 1 to 30 seconds |
GET /device/me?wait=25&known=unbound| Request | Reply |
|---|---|
No wait, no known, or known differs from the current state | Immediate, with the current binding or error |
known equals the current state | Held until the state changes or wait seconds pass, then the current binding or 403 DEVICE_UNBOUND, with X-A5-Held: 1 |
The reply body is the same with or without waiting. Set the request timeout above wait, for example 35 seconds for wait=25. After any reply, act on it as usual and store the new token. Ask again at once when the reply has X-A5-Held: 1 or a token that differs from known. When a reply comes back with the same token and no X-A5-Held, Optima did not hold the request: wait a few seconds before asking again. Errors other than DEVICE_UNBOUND are never held and carry no token; retry them with your usual backoff. An invalid wait or known returns 422.
See check the device's account.
See create a claim and redeem a claim.
Shared recorders
A shared recorder is hardware that people use one session at a time, such as a conference-room recorder. It is never an account device: it does not appear in GET /devices, and no account can claim it. Instead, any signed-in account with the devices:manage permission can start a session on an idle shared recorder. The session's host is the account that started it; the recorder records to the host's account until the session ends, and the host's turns stay after it ends. Other people in the room can join the session to appear in its participant list.
Each shared recorder has an NFC tag or QR code that opens the Optima app at https://getoptima.com/r/<recorder ID>. After sign-in, the page reads the recorder, offers to start a session or join the one in progress, and shows the host the time remaining with Extend and End. The app's Meetings page lists the sessions an account hosted or joined.
Start a session
-
Read the recorder.
GET /shared-recorders/{id}works for any signed-in account and returns:Field Description idThe recorder's ID, as on its tag nameA label such as a room name, or null onlineWhether the recorder contacted Optima in the last 90 seconds last_online_atThe recorder's last contact, or null sessionIts open session ( activeorending), or null when it is idleIt returns 404 for an ID that is not a shared recorder, including a personal device.
-
Start it.
POST /shared-recorders/{id}/sessionswith a length of 30, 60, 90, or 120 minutes (default 60):{ "duration_minutes": 60 }The 201 response is the session, with you as host. A recorder that already has an open session returns 409
SESSION_ACTIVE, and one that is not online returns 409RECORDER_OFFLINE. The recorder adopts the session within about a second and starts recording. -
Extend or end it. As host,
POST /devices/sessions/{id}/extendwith{ "minutes": 30 }moves the scheduled end 30 minutes later. A session lasts at most 240 minutes from its start; an extension past that returns 409SESSION_LIMIT.POST /devices/sessions/{id}/endstops recording now. Both return the session. Other participants receive 403FORBIDDEN, and accounts that never joined receive 404. Extending a session that is ending or ended returns 409CONFLICT; ending it again changes nothing.
When the scheduled end arrives, or the host ends the session, the session is ending: recording has stopped and the recorder has 15 minutes to upload the audio it captured. It then ends the session itself, or Optima ends it when the 15 minutes pass. Recordings go to the host's single source for that recorder, with kind shared_recorder and the recorder's label as its name. Every later session the host runs on the same recorder uses that source. Deleting the source ends a session that is still recording into it; it never affects the recorder or other accounts.
A phone can also start a session over Bluetooth: POST /devices/sessions with { "device_id": "<recorder ID>", "duration_minutes": 60 } returns a 10-minute claim, like a device claim, that the recorder redeems. It returns 404 when the ID is not an idle shared recorder.
See read a recorder, start a session, extend, end, and create a session claim.
Join a shared session
Other people in the room join from the recorder's tag. Being at the recorder is what admits them: any signed-in account with devices:manage can find and join an active session.
| Operation | Request |
|---|---|
| Find the open session on a recorder | GET /shared-recorders/{id} |
| Find the active session at a BLE address | GET /devices/sessions/by-address/{ble_address} (12 hex digits, either case) |
| List the sessions you hosted or joined | GET /devices/sessions, newest first, a { items, cursor } page |
| Read a session you joined | GET /devices/sessions/{id} |
| Join | POST /devices/sessions/{id}/join |
| Leave | POST /devices/sessions/{id}/leave |
| Read what a session you host produced | GET /devices/sessions/{id}/summary; see Session summary |
Each of the first six returns the session:
| Field | Description |
|---|---|
id, device_id, device_name | The session and its recorder. device_name is the recorder's label, or null. |
status | active while recording, ending while the recorder uploads the rest of its audio, then ended. Treat an unrecognized status as a session in progress. |
host | account_id and display_name of the account that started it, for a prompt such as "Join the session started by …" |
started_at, expires_at | When the session started and its scheduled end of recording. Extending or ending the session moves expires_at. |
ended_at | When the session ended, or null while it is active or ending |
end_reason | Why recording stopped: account (the host ended it, set as soon as the session is ending), expired (the scheduled end arrived), device (the recorder ended it early), source_deleted (the host deleted its source), or null while active. Treat an unrecognized reason generically. |
participants | Everyone who joined, host first: account_id, display_name, role (host or participant), joined_at, and left_at (null while present) |
display_name is the account's name; an account without one shows the local part of its email (the text before @), and Optima user only when it has no email. Sessions never expose full email addresses. Clients can set the name with PATCH /me.
The address lookup matches only an active session. Reading a session by ID returns 404 unless you joined it; participants can still read it after it ends or after they leave. Joining returns 404 when the session is no longer active. Joining again is safe, and you may join again after leaving, which clears left_at and updates joined_at. Leaving never ends the session and is safe to repeat; the host cannot leave (409 CONFLICT) and ends the session instead.
Joining a session gives you the participant list only. Transcripts, memories, and suggestions stay with the host's account; the host reads them through the session summary.
See list sessions, find a session by address, read a session, join, and leave.
Session summary
GET /devices/sessions/{id}/summary gives the host what a session produced: the conversations, memories, and suggestions Optima drew from the session's recording.
{
"session_id": "6f0e4a1c-3c55-4f0e-9d53-0c7d6a1f8b21",
"source_id": "7e17a59a-677e-4238-891d-3da6975370c2",
"pipeline_id": "0b9a3a3e-1111-4222-8333-444455556666",
"conversations": {
"items": [
{
"id": "c0c0c0c0-0000-4000-8000-000000000001",
"title": "Friday release planning",
"summary": "The team agreed to ship the onboarding fix this week.",
"maturity": "settled",
"started_at": "2026-09-30T17:01:00Z",
"ended_at": "2026-09-30T17:38:00Z"
}
],
"truncated": false
},
"memories": {
"items": [
{
"id": "d0d0d0d0-0000-4000-8000-000000000001",
"kind": "decision",
"title": "Onboarding fix ships this week",
"content": "The Android onboarding crash fix goes out before the Friday release.",
"timespan": { "from": "2026-09-30T17:05:00Z", "to": "2026-09-30T17:09:00Z", "incomplete": false }
}
],
"truncated": false
},
"suggestions": { "items": [], "truncated": false }
}| Field | Description |
|---|---|
session_id, source_id | The session, and your source for its recorder, which holds the recording |
pipeline_id | Your default pipeline, the one GET /suggestions reads without a pipeline_id. Every item comes from it. |
conversations.items | What was discussed, in recorded order: id, title, summary, maturity (provisional while it may still change, then settled), started_at, and ended_at. Treat an unrecognized maturity as provisional. |
memories.items | Things worth remembering, in recorded order: id, kind (summary, proposal, decision, commitment, question, or observation; show an unrecognized kind generically), title, content, and timespan, the recorded time it covers (from, to, and incomplete when part of it has no capture time) |
suggestions.items | Suggestions in any state, most recently updated first, in the same lean shape as GET /suggestions. Read GET /suggestions/{id} for evidence, and give feedback with POST /suggestions/{id}/feedback. |
suggestions.available | Deprecated and always true. Do not read it; it will be removed. |
truncated | Each list holds at most 50 items, keeping the latest conversations and the most recently updated suggestions. truncated is true when the session has more. |
An item belongs to a session when it comes from the session's source and its recorded time overlaps the session, from started_at to ended_at (or now, while the session is open). A recorder runs one session at a time, so the sessions a host runs on one recorder never share items by time. Items carry no transcript text: read the session's turns with GET /turns for source_id over the session's time range, which needs turns:read.
Processing continues after a session ends, so an early read can be empty or partial; read again later. A list is also empty when your pipeline produced nothing of that kind for the session; that is never an error. Retention and deletions apply as everywhere else: removed items are left out.
The summary belongs to the host. Another participant receives 403 FORBIDDEN, and an account that never joined receives 404. Reading a summary produces no event.
Access
The summary needs the insights:read permission and that you are the session's host. Conversations, memories, and suggestions are the insights layer, so a credential that may read insights may read it.
| Rule | Detail |
|---|---|
| Permission | insights:read. Without it the request returns 403 FORBIDDEN; turns:read alone is not enough. devices:manage, which the other session routes need, is not required: the summary manages nothing, and requiring it would shut out clients that read insights without managing devices. |
| Suggestions | Covered by the same permission, as for GET /suggestions. |
| Conversations and memories | An exception to pipeline diagnostics: elsewhere they need developer access (debug:read), and here insights:read is enough. The exposure is bounded to a lean title and text, at most 50 of each, for a meeting you hosted. Their evidence, references, and revisions stay in diagnostics. |
| Entitlements | The insights layer (insights.access), which every plan includes. No developer access is needed. |
| Data | Your own account's data only, as for GET /suggestions and GET /turns. A workspace role gives no access to another account's sessions or summaries, and other participants of the session get 403. |
| OAuth clients | Any OAuth token for the API origin with insights:read, including a third-party client's that requested it. A third-party client's default scope, turns:read, does not reach the summary. There is no MCP tool for the summary. |
| Personal access tokens | Work, with insights:read. |
Session events
Each account receives account events about its own participation, with resource.type device_session and the session ID:
| Event | Recipient |
|---|---|
device_session.started | The host. data.source_id is the host's source for the recorder. |
device_session.joined, device_session.left | The account that joined or left |
device_session.updated | Every current participant, including the host, with changed_fields ["expires_at"] when the host extends, or ["end_reason", "expires_at"] when the host ends it. Every other current participant also receives ["participants"] when someone joins or leaves. |
device_session.ended | Every account that joined, including those who left. The host's event includes data.source_id. |
The host's first session on a recorder also creates its source, which produces source.created; later sessions reuse it. A session reaching its scheduled end produces no event until it ends. Repeated joins, leaves, and ends produce no events. Read the session with GET /devices/sessions/{id} for its current state.
Shared recorder firmware
A shared recorder's firmware sends X-A5-Recorder-Mode: shared on every certificate-authenticated request; personal, or no header, is a personal device, and any other value returns 422. The mode decides how Optima treats the recorder:
| Change | Result |
|---|---|
A personal device bound to an account sends shared | 409 DEVICE_OWNED. Its owner must remove it first. |
An unbound personal device sends shared | It becomes a shared recorder. |
A shared recorder with an open session sends personal | 409 SHARED_SESSION_ACTIVE until the session ends. |
An idle shared recorder sends personal | It becomes a personal device that an account can claim. |
An idle shared recorder waits for a change on GET /device/me and receives 403 DEVICE_UNBOUND until a session starts. It waits the same way during a session, so it learns within about a second that the host extended or ended it. Waiting is cheap: Optima records the recorder's contact when each request arrives, at most every 30 seconds, and a recorder that asks again after every reply stays online. During a session, GET /device/me returns exactly five fields:
{
"owner_user_id": "<host account ID>",
"device_id": "<recorder ID>",
"binding_epoch": "<epoch>",
"binding_kind": "session",
"session_expires_at": 1790000000
}session_expires_at is the scheduled end of recording in integer Unix seconds; it changes when the host extends or ends the session. Owner bindings on personal devices never include these two fields. When session_expires_at is at or before the current time, the recorder stops capture, uploads its backlog, and calls POST /device/session/end with its epoch:
{ "binding_epoch": "<binding_epoch>" }It returns 204, and the recorder returns to idle. Once a session has ended, this and every other bound-device route return 403 DEVICE_UNBOUND, so a recorder retrying a lost end response treats that code as success. After the 15-minute drain window, the recorder receives 403 DEVICE_UNBOUND and resets without uploading the rest. A wrong epoch returns 409 BINDING_EPOCH_MISMATCH, and a personal device returns 409 CONFLICT.
When redeeming a session claim, the recorder adds its own BLE address so phones can find the session from a Bluetooth tag:
{ "claim_token": "<claim_token>", "ble_address": "AABBCCDDEEFF" }ble_address is 12 uppercase hex digits without colons, most significant byte first; any other form returns 422. Optima takes the address only from the recorder, never from a phone. An address already reported by another open session returns 409 CONFLICT. The reply has the same five fields as GET /device/me.
See end a session from the recorder and check the device's account.
Upload device audio
A bound device follows the same custody sequence as app audio uploads, with device paths and its binding epoch:
POST /device/streamsregisters a recording stream withsource_stream_ref,captured_at,clock_uncertainty_ms, andbinding_epoch. The device's source is selected from its binding.POST /device/chunks/intentsdeclares each chunk with a localsource_chunk_refand returns its echoed ref, a server-generatedchunk_id, andupload_path.PUTthe raw chunk bytes toupload_pathwithContent-Type: application/octet-streamand the exactContent-Length.POST /device/chunks/{chunk_id}/confirmreturns a durable custody receipt.POST /device/streams/{id}/endrecords where the recording ended, withend_msandbinding_epoch.idis the streamidfrom step 1.GET /device/chunks/{id}/statusreportswaiting,processing,ready, orfailed.
end_ms is in the stream's capture timeline: the source_end_ms of its last chunk, not the upload time. Queue the end behind the stream's buffered chunks and send it with that backlog, even hours after recording; it may arrive before the last chunks are confirmed. A recording that is already finished can instead send end_ms when it registers the stream. A stream response carries end_ms only once an end is recorded; an open stream's response has no end_ms key. The end is recorded once: the same end_ms returns the stream, and a different end, or one before a chunk the stream already has, returns 409 CONFLICT. A chunk intent past a recorded end also returns 409. Ending is optional; without it, Optima finalizes the recording after a period without new audio (longer for devices, whose uploads can pause), or soon after the device starts its next stream.
Use stable source_stream_ref and source_chunk_ref retry keys, and keep the original bytes until confirmation succeeds. Retry rules match the app flow: repeating a request with identical refs, metadata, and bytes is safe. The server allocates stream and chunk IDs. Use the returned chunk_id for confirmation; an intent 409 is a conflict, not proof of upload. A custody receipt means Optima holds the bytes; it does not mean transcription has finished.
Account clients read the results through /audio and /turns.
See register a stream, create a chunk intent, upload bytes, confirm a chunk, end a stream, and read processing status.
JSON and CBOR
Device metadata routes accept and return either JSON or CBOR. Choose each direction separately:
| Header | Controls | Values |
|---|---|---|
Content-Type | The request body | application/json or application/cbor. Anything else returns 415. |
Accept | The response body | application/json or application/cbor. Missing or wildcard Accept selects JSON. An unsupported value returns 406. |
Both representations use the same fields, success and error envelopes, and status codes. Structured request bodies are limited to 64 KiB. Chunk uploads are the exception: their request body is raw bytes, and only the acknowledgement uses the negotiated representation.
Related guides
- App-created
ios,macos, andwebsources upload with a bearer session instead. See Upload audio. - Devices report presence with
POST /device/activity. See Source activity.