Plum / developers

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.

InterfacePathsAuthenticationRequest and response bodies
Account/devices, /devices/claims, /devices/sessions, /shared-recordersBearer sessionJSON
Hardware/device/me, /device/claims/redeem, /device/session/end, /device/streams, /device/chunks/*, /device/activityVerified mTLS client certificateJSON 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

OperationRequest
List devicesGET /devices returns a { items, cursor } page
Read a deviceGET /devices/{id}
Rename a device sourcePATCH /sources/{source_id} with { "name": "Desk device" }, or null to clear the name
Remove a deviceDELETE /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.

FieldDescription
idThe device's permanent device ID
binding_kindowner. Shared recorders are never account devices.
session_idAlways null
sourceThe device's recording source, including its id, kind (device), name, and created_at
bound_atWhen the device was first bound to an account, or null
last_online_atWhen the device last made a certificate-verified request, or null
firmware_version, hardware_id, and other firmware fieldsReported 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.

  1. Create a claim. The account client calls POST /devices/claims with the device ID:

    { "device_id": "<device_id>" }

    The 201 response contains claim_token and expires_at. A claim expires after 10 minutes. A device can have only one open claim; creating another returns 409.

  2. Hand over the token. Pass claim_token to that device through your trusted setup channel. Treat it as a secret.

  3. Redeem the claim. The device calls POST /device/claims/redeem with its client certificate:

    { "claim_token": "<claim_token>" }

    The response contains exactly owner_user_id, device_id, and binding_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.

ReplyMeaningDevice behavior
Same owner and epochStill linkedContinue recording and uploading.
Different owner or epochClaimed againErase local account data and return to setup.
403 DEVICE_UNBOUNDRemoved from its account, or a shared recorder's session endedSame as above. A shared recorder keeps its saved network settings.
409 BINDING_EPOCH_MISMATCHThe request used a replaced bindingSame as above.
403 ACCOUNT_DELETEDThe 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 releasedPause 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.

StateToken changes when
No bindingThe device is bound or a session starts
Owner bindingThe binding is replaced or removed
Session bindingThe session is replaced, extended, ended by its host, or over

To wait, send the last token back with two query parameters:

ParameterValue
knownThe X-A5-Binding-State value of the last reply, 1 to 128 characters
waitHow long Optima may hold the request, 1 to 30 seconds
GET /device/me?wait=25&known=unbound
RequestReply
No wait, no known, or known differs from the current stateImmediate, with the current binding or error
known equals the current stateHeld 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

  1. Read the recorder. GET /shared-recorders/{id} works for any signed-in account and returns:

    FieldDescription
    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 (active or ending), or null when it is idle

    It returns 404 for an ID that is not a shared recorder, including a personal device.

  2. Start it. POST /shared-recorders/{id}/sessions with 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 409 RECORDER_OFFLINE. The recorder adopts the session within about a second and starts recording.

  3. Extend or end it. As host, POST /devices/sessions/{id}/extend with { "minutes": 30 } moves the scheduled end 30 minutes later. A session lasts at most 240 minutes from its start; an extension past that returns 409 SESSION_LIMIT. POST /devices/sessions/{id}/end stops recording now. Both return the session. Other participants receive 403 FORBIDDEN, and accounts that never joined receive 404. Extending a session that is ending or ended returns 409 CONFLICT; 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.

OperationRequest
Find the open session on a recorderGET /shared-recorders/{id}
Find the active session at a BLE addressGET /devices/sessions/by-address/{ble_address} (12 hex digits, either case)
List the sessions you hosted or joinedGET /devices/sessions, newest first, a { items, cursor } page
Read a session you joinedGET /devices/sessions/{id}
JoinPOST /devices/sessions/{id}/join
LeavePOST /devices/sessions/{id}/leave
Read what a session you host producedGET /devices/sessions/{id}/summary; see Session summary

Each of the first six returns the session:

FieldDescription
id, device_id, device_nameThe session and its recorder. device_name is the recorder's label, or null.
statusactive while recording, ending while the recorder uploads the rest of its audio, then ended. Treat an unrecognized status as a session in progress.
hostaccount_id and display_name of the account that started it, for a prompt such as "Join the session started by …"
started_at, expires_atWhen the session started and its scheduled end of recording. Extending or ending the session moves expires_at.
ended_atWhen the session ended, or null while it is active or ending
end_reasonWhy 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.
participantsEveryone 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 }
}
FieldDescription
session_id, source_idThe session, and your source for its recorder, which holds the recording
pipeline_idYour default pipeline, the one GET /suggestions reads without a pipeline_id. Every item comes from it.
conversations.itemsWhat 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.itemsThings 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.itemsSuggestions 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.availableDeprecated and always true. Do not read it; it will be removed.
truncatedEach 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.

RuleDetail
Permissioninsights: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.
SuggestionsCovered by the same permission, as for GET /suggestions.
Conversations and memoriesAn 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.
EntitlementsThe insights layer (insights.access), which every plan includes. No developer access is needed.
DataYour 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 clientsAny 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 tokensWork, with insights:read.

See read a session summary.

Session events

Each account receives account events about its own participation, with resource.type device_session and the session ID:

EventRecipient
device_session.startedThe host. data.source_id is the host's source for the recorder.
device_session.joined, device_session.leftThe account that joined or left
device_session.updatedEvery 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.endedEvery 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:

ChangeResult
A personal device bound to an account sends shared409 DEVICE_OWNED. Its owner must remove it first.
An unbound personal device sends sharedIt becomes a shared recorder.
A shared recorder with an open session sends personal409 SHARED_SESSION_ACTIVE until the session ends.
An idle shared recorder sends personalIt 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:

  1. POST /device/streams registers a recording stream with source_stream_ref, captured_at, clock_uncertainty_ms, and binding_epoch. The device's source is selected from its binding.
  2. POST /device/chunks/intents declares each chunk with a local source_chunk_ref and returns its echoed ref, a server-generated chunk_id, and upload_path.
  3. PUT the raw chunk bytes to upload_path with Content-Type: application/octet-stream and the exact Content-Length.
  4. POST /device/chunks/{chunk_id}/confirm returns a durable custody receipt.
  5. POST /device/streams/{id}/end records where the recording ended, with end_ms and binding_epoch. id is the stream id from step 1.
  6. GET /device/chunks/{id}/status reports waiting, processing, ready, or failed.

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:

HeaderControlsValues
Content-TypeThe request bodyapplication/json or application/cbor. Anything else returns 415.
AcceptThe response bodyapplication/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.

  • App-created ios, macos, and web sources upload with a bearer session instead. See Upload audio.
  • Devices report presence with POST /device/activity. See Source activity.

On this page