# Overview > What the Lobbe API does, and the loop an agent runs. Lobbe is meeting memory. You record or upload a meeting, Lobbe transcribes it and writes a Lobbe: the summary, the decisions and the action items, kept in your archive. A personal API token lets your own tools and agents do the same: submit a meeting, wait for it, read its Lobbe and ask about it. A meeting sent with a token spends the same minutes from your balance as one recorded in the app. ## The agent loop ```text submit POST /api/v1/recordings → 202 { recording: { id, status: QUEUED, minutes, statusUrl } } wait GET /api/v1/recordings/:id/status (60 s) → QUEUED → TRANSCRIBING → [NAMING] → GENERATING → READY └─ NAMING: { naming: { url, deadline } } hand naming.url to the person, or POST /api/v1/recordings/:id/naming { action: "skip" } read GET /api/v1/recordings/:id/bundle[?format=markdown] act GET /api/v1/recordings/search?q=… · POST /api/v1/recordings/:id/ask · POST /api/v1/series/:id/ask many GET /api/v1/events?after= (instead of one status poll per meeting) ``` :::note Sending meetings, creating and editing series, changing settings, search and Ask through a token are being switched on gradually. If the form in Connections doesn't offer a scope, it isn't available to your account yet. ::: ## Where next - [Quickstart: send a meeting](/start/quickstart/): make a token and take one meeting from audio to its Lobbe. - [Tokens and scopes](/start/tokens/): developer mode, the eight scopes, the daily cap and what no token can do. - [Minutes, caps and limits](/start/limits/): what spends minutes, rate limits and upload limits. - [API reference](/reference/): every route, its fields and its errors. --- # Quickstart: send a meeting > Make a token, send one meeting's audio, wait for it and read its Lobbe. This page takes one meeting through Lobbe with `curl`: you make a token, send the audio, follow its status, hand over the speakers' names and read the Lobbe. It takes a few minutes, most of them waiting. ## 1. Make a token You make tokens in the Lobbe app, and they work only while developer mode is on. 1. In the app, open **Account**. Under **Developers**, turn on **Developer mode**. It is off by default; once it is on, **Connections** appears below it. 2. Open **Connections** and tap **Create token**, or go straight to [app.lobbe.ai/connections/tokens/new](https://app.lobbe.ai/connections/tokens/new). 3. Name the token. Under **Meetings**, tick **Send meetings** and keep **Read**, which is ticked already. Pick when it expires, then tap **Create token**. 4. The token is shown once. Copy it and keep it in an environment variable: ```bash export LOBBE_TOKEN=lobbe_… ``` If the form doesn't offer **Send meetings**, sending meetings through a token isn't on for your account yet. The read-only version at the end of this page works with **Read** alone. ## 2. Check the token ```bash curl https://app.lobbe.ai/api/v1/whoami \ -H "Authorization: Bearer $LOBBE_TOKEN" ``` The answer says who the token belongs to and what it may do: ```json { "schemaVersion": 1, "owner": { "type": "user", "id": "USER_ID", "displayName": "Sara" }, "token": { "id": "TOKEN_ID", "name": "n8n office", "scopes": ["recordings:read", "recordings:write"], "dailyMinuteCap": null, "usedMinutes24h": 0, "expiresAt": "2027-01-17T09:12:00.000Z" }, "wallet": null, "limits": { "maxUploadMb": 300, "maxRecordingMinutes": 240 } } ``` `wallet` is `null` unless the token holds `account:read`. `limits` is the largest file and the longest meeting Lobbe takes right now: see [Minutes, caps and limits](/start/limits/). Every field is in [`GET /whoami`](/reference/token/#whoami). If this answers `401` or `403`, `error.code` names the problem. `403 DEVELOPER_MODE_OFF` means developer mode is off. [Errors](/guides/errors/) lists every code. ## 3. Send a meeting ```bash curl https://app.lobbe.ai/api/v1/recordings \ -H "Authorization: Bearer $LOBBE_TOKEN" \ -F "audio=@meeting.m4a;type=audio/mp4" ``` The body is `multipart/form-data`, with the file in `audio` and its type after `;type=`. Lobbe answers `202`: ```json { "schemaVersion": 1, "recording": { "id": "MEETING_ID", "status": "QUEUED", "minutes": 42, "recordedAt": "2026-10-19T08:30:00.000Z", "statusUrl": "https://app.lobbe.ai/api/v1/recordings/MEETING_ID/status", "bundleUrl": null, "url": "https://app.lobbe.ai/m/MEETING_ID" } } ``` Keep `id`. `minutes` is what the meeting costs, from your balance. The other fields you can send (when the meeting happened, a title, a series, the kind of recording, the Lobbe's language, naming) are in [`POST /recordings`](/reference/recordings/#submitRecording). To send the request again safely, without making a second meeting, add an `Idempotency-Key` header: see [Idempotency and retries](/guides/idempotency/). Without `;type=`, curl labels the file `application/octet-stream`. Lobbe recognizes most audio files from their first bytes anyway, but not all: `400 VALIDATION` with the message `Unsupported audio type` means Lobbe couldn't tell what the file is, so name its type. The accepted files and their types are listed in [Minutes, caps and limits](/start/limits/). ## 4. Wait for it Ask for the status about once a minute: ```bash curl https://app.lobbe.ai/api/v1/recordings/MEETING_ID/status \ -H "Authorization: Bearer $LOBBE_TOKEN" ``` `status` moves through these values, in this order: | `status` | What is happening | | --- | --- | | `QUEUED` | Lobbe has the audio, and the meeting is waiting its turn | | `TRANSCRIBING` | The audio is being transcribed | | `NAMING` | Waiting for the speakers' names. Not every meeting stops here | | `GENERATING` | The Lobbe is being written | | `READY` | Done: read it | | `FAILED` | It stopped. `error.code` says why, and `error.retryable` says whether [`POST /recordings/{id}/retry`](/reference/recordings/#retryRecording) can help | `FAILED` isn't always the end: Lobbe retries a failed step by itself after a short wait, so a status read can catch a `FAILED` that then moves on. The loop below waits until there is something to do. At `FAILED` with `error.retryable` set to `true`, it retries the meeting and keeps waiting, up to three times; at a `FAILED` that can't recover, or on an error, it stops and prints the answer (it needs `jq`, and curl 7.76 or later for `--fail-with-body`): ```bash retries=0 while true; do body=$(curl -sS --fail-with-body https://app.lobbe.ai/api/v1/recordings/MEETING_ID/status \ -H "Authorization: Bearer $LOBBE_TOKEN") || { echo "$body"; break; } status=$(jq -r .status <<<"$body") echo "$status" case "$status" in NAMING | READY) break ;; esac if [ "$status" = FAILED ]; then if [ "$(jq -r .error.retryable <<<"$body")" != true ] || [ "$retries" -ge 3 ]; then echo "$body" break fi retries=$((retries + 1)) # 409 NOT_FAILED: it recovered by itself in the meantime. Keep waiting either way. body=$(curl -sS --fail-with-body -X POST \ https://app.lobbe.ai/api/v1/recordings/MEETING_ID/retry \ -H "Authorization: Bearer $LOBBE_TOKEN") || [ "$(jq -r .error.code <<<"$body" 2>/dev/null)" = NOT_FAILED ] || { echo "$body"; break; } fi sleep 60 done ``` The retry is safe while Lobbe is still retrying by itself: it never starts a second run, and it costs nothing more. See [`FAILED` and retry](/guides/lifecycle/#failed-and-retry). Following many meetings? Read the events feed, [`GET /events`](/reference/events/#listEvents), instead of asking for each meeting's status. ## 5. Name the speakers, or skip At `NAMING`, the status carries `naming`: ```json { "schemaVersion": 1, "id": "MEETING_ID", "status": "NAMING", "title": null, "error": null, "naming": { "url": "https://app.lobbe.ai/m/MEETING_ID?name=speakers", "deadline": "2026-10-20T08:45:00.000Z" }, "bundleUrl": null, "url": "https://app.lobbe.ai/m/MEETING_ID" } ``` You have two choices: - **Hand `naming.url` to the person.** It opens the meeting's speaker naming in the app, for the account's owner only, signed in. So it is safe to send in a chat or an email. - **Skip naming:** ```bash curl https://app.lobbe.ai/api/v1/recordings/MEETING_ID/naming \ -H "Authorization: Bearer $LOBBE_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "action": "skip" }' ``` Unnamed voices take Lobbe's suggestions, and the Lobbe is made. The answer is `200` with the meeting's status. A meeting that isn't at `NAMING` answers `409 NOT_NAMING`. If neither happens, Lobbe goes ahead without names from `naming.deadline` on. When it is `null`, Lobbe waits for you. To never stop at naming, send `-F naming=skip` with the meeting. ## 6. Read the Lobbe At `READY`: ```bash curl "https://app.lobbe.ai/api/v1/recordings/MEETING_ID/bundle?format=markdown" \ -H "Authorization: Bearer $LOBBE_TOKEN" ``` The answer is one Markdown document: the title, the meeting's details, the Lobbe's sections and the transcript. Without `?format=markdown` you get the same bundle as JSON; its fields are in [`GET /recordings/{id}/bundle`](/reference/recordings/#getRecordingBundle). Reading a bundle needs `recordings:read`. ## With a read-only token A token with **Read** alone can't send meetings, but it reads your finished meetings: ```bash export LOBBE_TOKEN=lobbe_… # Check the token curl https://app.lobbe.ai/api/v1/whoami \ -H "Authorization: Bearer $LOBBE_TOKEN" # Your finished meetings, newest first curl https://app.lobbe.ai/api/v1/recordings/ready \ -H "Authorization: Bearer $LOBBE_TOKEN" # One whole meeting, as Markdown curl "https://app.lobbe.ai/api/v1/recordings/MEETING_ID/bundle?format=markdown" \ -H "Authorization: Bearer $LOBBE_TOKEN" ``` :::note Sending meetings, creating and editing series, changing settings, search and Ask through a token are being switched on gradually. If the form in Connections doesn't offer a scope, it isn't available to your account yet. ::: ## Next - [Tokens and scopes](/start/tokens/): developer mode, the eight scopes, the daily cap and what no token can do. - [Minutes, caps and limits](/start/limits/): what spends minutes, rate limits and upload limits. - [API reference](/reference/): every route, its fields and its errors. --- # Tokens and scopes > What a personal API token is, what each scope opens, the daily cap, and what no token can do. A personal API token lets your own tools and agents work with Lobbe for you. ## Developer mode Tokens need **Developer mode**: in the app, open **Account** and find it in the **Developers** section. It is off for a new account. Turn it on and **Connections** appears, where you make and manage tokens. Turning developer mode off pauses every token of your account: each request answers `403 DEVELOPER_MODE_OFF`, and no token is revoked. Turn it back on and the same tokens work again. ## How a token works - A token belongs to one person, the account's owner, and acts for that account: what it sends spends that account's minutes, and what it reads is that account's. - Every request sends it in the `Authorization` header: `Authorization: Bearer $LOBBE_TOKEN`. - The app shows a token once, when you make it. Lobbe keeps only a hash of it, so a lost token can't be shown again: make a new one. - You can have up to 10 active tokens at a time. Revoked and expired tokens don't count. - A token expires after 30, 90 or 365 days, or never (**Never**). You choose when you make it. - A token's scopes are fixed when you make it. If a tool needs more or less, make a new token and revoke the old one. - To revoke a token, open it in **Connections** and tap **Revoke** twice. It stops working at once, and anything it already sent stays in your archive. ## The scopes Each scope opens one kind of access. The create form shows the eight in three groups: - **Meetings:** `recordings:read`, `recordings:write`, `recordings:query` - **Series:** `series:read`, `series:write`, `series:query` - **Account:** `account:read`, `account:write` | Scope | In the form | What it does | Routes | Spends minutes | Rollout switch | | --- | --- | --- | --- | --- | --- | | `recordings:read` | Read | Lists your finished meetings and reads their transcript, Lobbe and status. | [`GET /recordings/ready`](/reference/recordings/#listReadyRecordings), [`GET /recordings/{id}/bundle`](/reference/recordings/#getRecordingBundle), [`GET /recordings/{id}/status`](/reference/recordings/#getRecordingStatus), [`GET /events`](/reference/events/#listEvents) | No | None | | `recordings:write` | Send meetings | Sends audio to make a Lobbe; can skip naming or retry a failed meeting. | [`GET /recordings/{id}/status`](/reference/recordings/#getRecordingStatus), [`GET /events`](/reference/events/#listEvents), [`POST /recordings`](/reference/recordings/#submitRecording), [`POST /recordings/{id}/naming`](/reference/recordings/#skipRecordingNaming), [`POST /recordings/{id}/retry`](/reference/recordings/#retryRecording) | Yes | `API_WRITE_ENABLED` | | `recordings:query` | Search and ask | Searches your meetings and asks them questions. | [`GET /recordings/search`](/reference/recordings/#searchRecordings), [`GET /recordings/{id}/ask/quote`](/reference/recordings/#quoteRecordingAsk), [`POST /recordings/{id}/ask`](/reference/recordings/#askRecording) | Yes | `API_QUERY_ENABLED` | | `series:read` | Read | Lists your series and reads their settings and vocabulary. | [`GET /series/readable`](/reference/series/#listReadableSeries), [`GET /series/{id}`](/reference/series/#getSeries), [`GET /series/{id}/keyterms`](/reference/series/#getSeriesKeyterms) | No | None | | `series:write` | Create and edit | Creates a series and changes its name, settings and vocabulary. | [`POST /series`](/reference/series/#createSeries), [`PATCH /series/{id}`](/reference/series/#updateSeries), [`PUT /series/{id}/keyterms`](/reference/series/#replaceSeriesKeyterms) | No | `API_WRITE_ENABLED` | | `series:query` | Ask a series | Asks a question of every finished meeting in a series. | [`GET /series/{id}/ask/quote`](/reference/series/#quoteSeriesAsk), [`POST /series/{id}/ask`](/reference/series/#askSeries) | Yes | `API_QUERY_ENABLED` | | `account:read` | See balance and settings | Reads your balance, settings, vocabulary and memory. | [`GET /account`](/reference/account/#getAccount), [`GET /account/keyterms`](/reference/account/#getAccountKeyterms), [`GET /account/memory`](/reference/account/#listMemory) | No | None | | `account:write` | Change settings | Changes your settings and vocabulary, and edits or deletes memory notes. | [`PATCH /account/settings`](/reference/account/#updateAccountSettings), [`PUT /account/keyterms`](/reference/account/#replaceAccountKeyterms), [`PATCH /account/memory/{id}`](/reference/account/#updateMemoryItem), [`DELETE /account/memory/{id}`](/reference/account/#deleteMemoryItem) | No | `API_WRITE_ENABLED` | - Only `recordings:read` (**Meetings**, **Read**) is ticked by default. No scope is required, but a token needs at least one. - [`GET /whoami`](/reference/token/#whoami) answers every token, whatever its scopes. - Scopes don't include one another. A token that can send meetings but not read them (`recordings:write` without `recordings:read`) reaches only the meetings it sent itself, and of those only their status and their events: never their transcript, never their Lobbe, and never another meeting. - `recordings:query` without `recordings:read` has no list of meetings and no bundle, but search finds any of your finished meetings by its words, and the token can then ask that meeting. ## The daily cap A token that can spend minutes can have a daily cap: the most minutes it may spend in any 24 hours. - It is optional, and the default is **Unlimited**. The form shows **Daily spending cap** only when you tick a scope that spends. - A day here is any 24 hours in a row: each request is checked against what the token spent in the 24 hours just before it. - It counts this token's own spending only: each meeting it sends, from the moment Lobbe accepts it, and the price of each Ask. A meeting you delete later still counts. - A request that would go over answers `429 TOKEN_DAILY_CAP`: see [Minutes, caps and limits](/start/limits/). - Change it on the token's page in **Connections**, under **Daily cap**: a whole number of minutes, from 1 to 100,000. :::caution A token with no cap, in someone else's hands, can spend every minute of your balance. ::: ## What no token can do No scope gives a token these. They stay with you, in the app: - deleting a meeting or a series; - showing anything to another person: share links, series invites and members, a series' email list, sending a summary by email; - buying minutes, and payments; - changing your name, phone number, email, password or sign-in sessions, or the **Delete audio after the Lobbe** setting; - making or revoking tokens, and turning developer mode on or off; - moving a meeting you already sent into a series (a token can file a meeting in a series when it sends it). ## Rollout :::note Sending meetings, creating and editing series, changing settings, search and Ask through a token are being switched on gradually. If the form in Connections doesn't offer a scope, it isn't available to your account yet. ::: ## Keep it safe - Never put a token in a URL: addresses end up in logs and in browser history. - Never commit a token to a repository. Read it from an environment variable or a secret store. - Never paste a token into a chat, an issue tracker or an email, a chat with an AI assistant included. - Give each tool its own token, with the fewest scopes it needs and, if it spends, a daily cap. Then you can revoke one without stopping the others. - If a token leaks, revoke it in **Connections** at once. --- # Minutes, caps and limits > What spends minutes, what happens when they run short, the token's daily cap, rate limits and upload limits. ## Minutes Lobbe is paid for in minutes. A token spends from the same balance the app uses: a meeting sent through the API costs what the same meeting costs in the app. ## What spends Only three scopes can spend minutes. Every other route is free: reading, search and the Ask quotes included. | Scope | What spends | How much | | --- | --- | --- | | `recordings:write` | Sending a meeting, [`POST /recordings`](/reference/recordings/#submitRecording) | The meeting's minutes: `minutes` in the `202` | | `recordings:query` | Asking a meeting, [`POST /recordings/{id}/ask`](/reference/recordings/#askRecording) | The price [`GET /recordings/{id}/ask/quote`](/reference/recordings/#quoteRecordingAsk) gives | | `series:query` | Asking a series, [`POST /series/{id}/ask`](/reference/series/#askSeries) | The price [`GET /series/{id}/ask/quote`](/reference/series/#quoteSeriesAsk) gives | **Sending a meeting.** `minutes` in the `202` is what the meeting costs: the audio's length, plus the vocabulary surcharge when `useKeyterms` applies, plus the attachment. From the moment Lobbe accepts the meeting, those minutes are held: the next meeting you send can't spend them. The charge itself is taken when the transcript is ready. A meeting whose transcription fails costs nothing, and so does one you delete before its transcript is ready. One that waits at naming, or fails while its Lobbe is being written, is charged, and a retry costs nothing more. **Asking.** An Ask spends the price its quote gives. Send `maxMinutes`, the most you will pay: if the price is higher, the answer is `409 PRICE_ABOVE_MAX` and nothing is spent. An answer the model declines to give is charged too. An Ask that fails (`502 GENERATE_FAILED`) is not. One cost is indirect: a vocabulary you write with `account:write` or `series:write` adds the vocabulary surcharge to the later meetings that use it. :::note Sending meetings, creating and editing series, changing settings, search and Ask through a token are being switched on gradually. If the form in Connections doesn't offer a scope, it isn't available to your account yet. ::: ## Running short When the balance doesn't cover a request, the answer is `402 INSUFFICIENT_MINUTES` and nothing is spent. `error.details` carries: - `requiredMinutes`: how many minutes the request needs; - `balanceMinutes`, only for a token holding `account:read`: for a meeting you send, your balance less the meetings still in progress; for an Ask, the balance itself, the same number as `wallet.balanceMinutes`. Don't send the request again until the owner buys minutes. `wallet.balanceMinutes` in `whoami` and `GET /account` doesn't take out meetings whose transcript isn't ready yet, so it can be more than a new meeting may spend. Once, an account that got its trial minutes (they come with a verified phone number) and has never bought minutes may send one meeting its balance doesn't cover, as long as the shortfall stays within a set limit. The balance then goes below 0 until a purchase. Lobbe also has its own daily limits on each account, for meetings and for Asks. Reaching one answers `429 DAILY_LIMIT_REACHED`, and no token raises them. ## The token's daily cap When a token has a [daily cap](/start/tokens/) and a request would take it over, the answer is `429 TOKEN_DAILY_CAP` and nothing is spent. `error.details` carries `capMinutes` (the cap), `usedMinutes` (what the token spent in the last 24 hours) and `requiredMinutes` (what this request costs). `Retry-After` gives the seconds to wait until older spending leaves the 24 hours and makes room for this request. When it is missing, this request alone is bigger than the cap and waiting can't help: raise the cap in **Connections**. ## Rate limits Each token has a budget of requests for each class of route, over any 10 minutes: | Class | Budget per token | Routes | | --- | --- | --- | | `read` | 600 requests per 10 minutes | [`GET /whoami`](/reference/token/#whoami), [`GET /recordings/ready`](/reference/recordings/#listReadyRecordings), [`GET /recordings/{id}/bundle`](/reference/recordings/#getRecordingBundle), [`GET /recordings/{id}/status`](/reference/recordings/#getRecordingStatus), [`GET /events`](/reference/events/#listEvents), [`GET /series/readable`](/reference/series/#listReadableSeries), [`GET /series/{id}`](/reference/series/#getSeries), [`GET /series/{id}/keyterms`](/reference/series/#getSeriesKeyterms), [`GET /account`](/reference/account/#getAccount), [`GET /account/keyterms`](/reference/account/#getAccountKeyterms), [`GET /account/memory`](/reference/account/#listMemory) | | `submit` | 30 requests per 10 minutes | [`POST /recordings`](/reference/recordings/#submitRecording), [`POST /recordings/{id}/naming`](/reference/recordings/#skipRecordingNaming), [`POST /recordings/{id}/retry`](/reference/recordings/#retryRecording) | | `search` | 120 requests per 10 minutes | [`GET /recordings/search`](/reference/recordings/#searchRecordings), [`GET /recordings/{id}/ask/quote`](/reference/recordings/#quoteRecordingAsk), [`GET /series/{id}/ask/quote`](/reference/series/#quoteSeriesAsk) | | `ask` | 30 requests per 10 minutes | [`POST /recordings/{id}/ask`](/reference/recordings/#askRecording), [`POST /series/{id}/ask`](/reference/series/#askSeries) | | `settings` | 60 requests per 10 minutes | [`POST /series`](/reference/series/#createSeries), [`PATCH /series/{id}`](/reference/series/#updateSeries), [`PUT /series/{id}/keyterms`](/reference/series/#replaceSeriesKeyterms), [`PATCH /account/settings`](/reference/account/#updateAccountSettings), [`PUT /account/keyterms`](/reference/account/#replaceAccountKeyterms), [`PATCH /account/memory/{id}`](/reference/account/#updateMemoryItem), [`DELETE /account/memory/{id}`](/reference/account/#deleteMemoryItem) | - The budget belongs to the class, not to a route: every route in a class draws on the same budget. Polling `GET /events` spends the same `read` budget as reading bundles, status and `whoami`. - Each token has budgets of its own, so one agent can't use up another's. - Over the budget, a request answers `429 RATE_LIMITED` with `Retry-After`: the seconds to wait. Wait that long, and don't send it sooner. - On some routes Lobbe also limits the requests from one IP address, whatever the token. Those answer `429 RATE_LIMITED` with `Retry-After` too. - Asking for a meeting's status about once a minute stays far inside the budget. ## Upload limits `whoami` gives the current limits in `limits`: - `limits.maxUploadMb`: the largest audio file, in megabytes. A bigger one answers `413 UPLOAD_TOO_LARGE`. - `limits.maxRecordingMinutes`: the longest meeting, in minutes of audio. A longer one answers `413 RECORDING_TOO_LONG`. They can change, so read them from [`GET /whoami`](/reference/token/#whoami) rather than writing them into your code. **Accepted files.** `audio` takes one audio file: | Format | Type to send | | --- | --- | | M4A, MP4 audio | `audio/mp4` | | MP3 | `audio/mpeg` | | WAV | `audio/wav` | | WebM | `audio/webm` | | Ogg | `audio/ogg` | | AAC | `audio/aac` | | FLAC | `audio/flac` | Lobbe reads the type from the file part's `Content-Type`. When it is missing or isn't an audio type (curl sends `application/octet-stream` unless you add `;type=…`), Lobbe recognizes MP3, AAC, WAV, FLAC, Ogg and most M4A files from their first bytes. A WebM file needs its type: `-F "audio=@meeting.webm;type=audio/webm"`. A file Lobbe can't place answers `400 VALIDATION`, one it can't read answers `400 INVALID_AUDIO`, and an M4A or MP4 file cut short answers `400 AUDIO_INCOMPLETE`. A video file (`video/mp4`, `video/quicktime`, `video/webm` or `video/x-matroska`) is accepted only while video upload is on, and Lobbe keeps only its audio. Send its type too: `-F "audio=@meeting.mp4;type=video/mp4"`. Lobbe doesn't recognize a video from its bytes, so without the type it answers `400 VALIDATION`. While video upload is off, a video answers `409 VIDEO_UPLOAD_DISABLED`. `attachment` takes one PDF, PPTX or DOCX file of up to 10 MB, recognized by its file name's extension. It adds minutes to the meeting by its size. --- # The meeting lifecycle and speaker naming > The statuses a meeting moves through, what it costs, the naming step and how to skip it, failures and retries, and the meeting time. A meeting you send is worked on in the background: Lobbe transcribes it, may wait for the speakers' names, then writes its Lobbe. This page follows one meeting from the `202` to `READY`, and covers what can stop it on the way. ## The statuses [`GET /recordings/{id}/status`](/reference/recordings/#getRecordingStatus) gives the meeting's `status`: | `status` | What it means | | --- | --- | | `QUEUED` | Lobbe has the audio, and the meeting is waiting its turn | | `TRANSCRIBING` | The audio is being transcribed | | `NAMING` | The transcript is ready, and the meeting is waiting for its speakers' names, or a skip | | `GENERATING` | The Lobbe is being written | | `READY` | Done: its bundle can be read | | `FAILED` | It stopped, and `error.code` says why | - On its way to `READY`, every meeting passes through `QUEUED`, `TRANSCRIBING` and `GENERATING`. Only `NAMING` can be skipped: see [Speaker naming](/guides/lifecycle/#speaker-naming). - A status can also go back. A [retry](/guides/lifecycle/#failed-and-retry) sends a `FAILED` meeting back to the step that failed. When the person regenerates the Lobbe in the app (**Regenerate the Lobbe**), a `READY` meeting is `GENERATING` again until the new Lobbe is ready. When they ask Lobbe to **Check the meeting again** from the naming sheet, it goes back to `QUEUED`. - Asking once a minute, you won't see every status. Don't wait for one in particular: act on `NAMING`, `READY` or `FAILED` whenever you see it. - Once the meeting is deleted, its status answers `404`. How often to ask, and how to follow many meetings at once, is in [Polling and the events feed](/guides/polling-and-events/). ### What a meeting costs The `202` from [`POST /recordings`](/reference/recordings/#submitRecording) carries `minutes`: the audio's length, plus the vocabulary surcharge when `useKeyterms` applies, plus the attachment. - From the moment Lobbe accepts the meeting, those minutes are held: the next meeting you send can't spend them. - They are charged once, when the transcript is ready. So a meeting that waits at `NAMING`, or fails while its Lobbe is being written, has already paid. - A meeting whose transcription fails costs nothing, and neither does one deleted before its transcript is ready. - If Lobbe can't use the attachment in the end, its minutes are left out of the charge. What happens when the balance runs short, and the token's daily cap, are in [Minutes, caps and limits](/start/limits/). ## Speaker naming ### Why a meeting waits When the transcript has two or more voices, Lobbe can stop at `NAMING` so the person names the speakers before the Lobbe is written: its decisions and action items then carry real names instead of "Speaker 1". Whether it stops, and for how long, follows the owner's **Name speakers** setting in the app: | Setting | `speakerNamingMode` | What happens | | --- | --- | --- | | **Ask** | `FORCE_ON` | The meeting waits until the speakers are named or skipped, or until `naming.deadline` passes | | **Ask, but don't wait** | `AUTO` | It waits a short while, then goes on by itself | | **Auto-guess** | `FORCE_OFF` | It never waits, and Lobbe guesses the names where it can | A meeting with a single voice can stop at `NAMING` for a short while too, so someone watching can still name it. Lobbe doesn't notify the person about that one. A token holding `account:read` reads the setting as `settings.speakerNamingMode` in [`GET /account`](/reference/account/#getAccount). ### At `NAMING` The status carries `naming`: ```json "naming": { "url": "https://app.lobbe.ai/m/MEETING_ID?name=speakers", "deadline": "2026-10-20T08:45:00.000Z" } ``` - **`naming.url`** opens the meeting's naming sheet in the app, where the person listens to each voice and writes a name. It opens only for the account's owner, signed in, so it is safe to send in a chat or an email. Opened after the meeting has moved on, it just opens the meeting. - **`naming.deadline`** is when Lobbe goes ahead without the names. At that moment or a few minutes after it, the Lobbe is written with the names Lobbe can fill in itself (voices it recognizes, for example), and the person can still name the speakers later. How far off it is depends on the setting: a short while with **Ask, but don't wait**, hours with **Ask**. - **`naming.deadline` can be `null`**, with **Ask**: Lobbe then waits for the person, with no end. - **The deadline can move later** when the person opens the naming sheet. Read it from the latest status rather than keeping the first one. ### Skipping naming [`POST /recordings/{id}/naming`](/reference/recordings/#skipRecordingNaming) with `{ "action": "skip" }` does what **Skip** does on the naming sheet: each voice nobody named takes Lobbe's suggestion where it has one, and the Lobbe is written. ```bash curl https://app.lobbe.ai/api/v1/recordings/MEETING_ID/naming \ -H "Authorization: Bearer $LOBBE_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "action": "skip" }' ``` - The answer is `200` with the meeting's status, the same body as `GET /recordings/{id}/status`. - A meeting that isn't at `NAMING` (it has moved on, or hasn't got there yet) answers `409 NOT_NAMING`. Read its status instead of skipping again. - `skip` is the only action: a token can't write names. Naming is the person's, through `naming.url`. - Skipping costs nothing. It needs `recordings:write`, and it works on your own meetings, never on those of a series you joined. Without `recordings:read`, only on meetings this token sent. ### Skipping naming at upload To never stop at `NAMING`, send `naming=skip` with the meeting: ```bash curl https://app.lobbe.ai/api/v1/recordings \ -H "Authorization: Bearer $LOBBE_TOKEN" \ -F "audio=@meeting.m4a;type=audio/mp4" \ -F naming=skip ``` The meeting goes from its transcript straight to its Lobbe, and voices are named as a skip names them. Without it (`naming=default`), the owner's **Name speakers** setting decides. ## `FAILED` and retry At `FAILED`, the status carries `error`: ```json "error": { "code": "TRANSCRIBE_FAILED", "retryable": true } ``` - **`error.code`** says which step failed: `TRANSCRIBE_FAILED` or `GENERATE_FAILED`. `NO_SPEECH` means the audio has no speech in it, and `AUDIO_CORRUPT` that the transcription service refused the audio as damaged. - **`error.retryable`** says whether a retry can help. It is `false` for `NO_SPEECH` and `AUDIO_CORRUPT`: the same audio fails the same way again. - **`FAILED` can be temporary.** Lobbe retries a failed step by itself, and the meeting may move on without you. The events feed sends `recording.failed` only once a failure is final. [`POST /recordings/{id}/retry`](/reference/recordings/#retryRecording) runs the failed step again (a transcript that is ready is never redone) and answers `200` with the status: ```bash curl -X POST https://app.lobbe.ai/api/v1/recordings/MEETING_ID/retry \ -H "Authorization: Bearer $LOBBE_TOKEN" ``` - A meeting that isn't `FAILED` answers `409 NOT_FAILED`. - A retry costs nothing more: a meeting that failed while its Lobbe was being written has paid already, and one whose transcription failed pays only once, when its transcript is ready. - If Lobbe is still retrying the step by itself, your retry doesn't start a second run. - An `AUDIO_CORRUPT` meeting isn't run again: the answer is its status, still `FAILED`. - A `NO_SPEECH` meeting is run again. Lobbe lets a few silent meetings a day fail for free; past that, a silent meeting gets an empty Lobbe and is charged. Retry one only if you know the audio has speech in it. - Retry needs `recordings:write` and reaches the same meetings as a skip. ## The meeting time `recordedAt` is when the meeting happened. Send it with the meeting, in ISO 8601 with an offset: ```bash curl https://app.lobbe.ai/api/v1/recordings \ -H "Authorization: Bearer $LOBBE_TOKEN" \ -F "audio=@meeting.m4a;type=audio/mp4" \ -F recordedAt=2026-10-19T10:30:00+03:30 ``` - Without it, `recordedAt` is the moment Lobbe receives the meeting. - It may be up to 5 minutes in the future (clocks drift) and not before 2000. Anything else answers `400 VALIDATION`. - The app shows this time on the meeting and sorts the archive and series pages by it, so a meeting held last Tuesday and sent today sits with last Tuesday's. [Search](/reference/recordings/#searchRecordings) orders by it too, and its `from`/`to` filters read it. - `createdAt` is when Lobbe received the meeting. The list of finished meetings ([`GET /recordings/ready`](/reference/recordings/#listReadyRecordings)) and the events feed keep that order, so a meeting sent with an earlier `recordedAt` is never missed by a script that reads them. :::note Sending meetings, creating and editing series, changing settings, search and Ask through a token are being switched on gradually. If the form in Connections doesn't offer a scope, it isn't available to your account yet. ::: --- # Polling and the events feed > How often to ask for a meeting's status, and how to follow all your meetings with one feed instead. Lobbe works on a meeting in the background, so your tool finds out by asking. There are two ways: ask for one meeting's status, or read the events feed, which says what changed across all your meetings. ## Polling one meeting Ask for [`GET /recordings/{id}/status`](/reference/recordings/#getRecordingStatus) about once a minute. Most meetings are done within a few minutes, and once a minute stays far inside the token's `read` budget (see [Rate limits](/start/limits/#rate-limits)). - Over the budget, the answer is `429 RATE_LIMITED` with `Retry-After`: the seconds to wait. Wait that long, and don't ask sooner. - Stop at `NAMING` or `READY`: the first needs you, the second ends the wait. What to do at each is in [The meeting lifecycle](/guides/lifecycle/). - **`FAILED` can be temporary.** Lobbe retries a failed step by itself after a short wait, so a poll can catch a `FAILED` that then moves on. When `error.retryable` is `true`, [retry it](/guides/lifecycle/#failed-and-retry) and keep waiting: the retry never starts a second run while Lobbe's own is pending, and `409 NOT_FAILED` means the meeting has already recovered. Stop after a few retries. When `error.retryable` is `false`, stop and report `error.code`. Retrying needs `recordings:write`. A loop that waits a minute between asks, honors `Retry-After`, retries a meeting that can recover (up to three times), and stops on anything else, printing it (it needs `jq`): ```bash retries=0 while true; do code=$(curl -sS -o status.json -D headers.txt -w '%{http_code}' \ https://app.lobbe.ai/api/v1/recordings/MEETING_ID/status \ -H "Authorization: Bearer $LOBBE_TOKEN") || break wait=$(awk -F': *' 'tolower($1) == "retry-after" { print $2 + 0 }' headers.txt) if [ "$code" = 429 ] && [ -n "$wait" ]; then sleep "$wait"; continue; fi [ "$code" = 200 ] || { cat status.json; break; } status=$(jq -r .status status.json) echo "$status" case "$status" in NAMING | READY) break ;; esac if [ "$status" = FAILED ]; then if [ "$(jq -r .error.retryable status.json)" != true ] || [ "$retries" -ge 3 ]; then cat status.json break fi retries=$((retries + 1)) code=$(curl -sS -o retry.json -w '%{http_code}' -X POST \ https://app.lobbe.ai/api/v1/recordings/MEETING_ID/retry \ -H "Authorization: Bearer $LOBBE_TOKEN") || break # 200: the step runs again. 409 NOT_FAILED: it recovered by itself. Either way, keep waiting. [ "$code" = 200 ] || [ "$(jq -r .error.code retry.json 2>/dev/null)" = NOT_FAILED ] || { cat retry.json; break; } fi sleep 60 done ``` ## The events feed Following more than a few meetings, read [`GET /events`](/reference/events/#listEvents) instead of asking for each one's status. It is one list of what happened to your meetings, oldest first: ```json { "schemaVersion": 1, "events": [ { "id": "1042", "type": "recording.naming_required", "recordingId": "MEETING_ID", "seriesId": null, "occurredAt": "2026-10-19T09:14:03.120Z", "data": { "status": "NAMING" } } ], "nextCursor": "773.1042.1792300000000" } ``` ### The event types | `type` | When it is sent | `data` | | --- | --- | --- | | `recording.created` | Lobbe has the meeting in full: recorded in the app, uploaded, or sent by a token | `status` | | `recording.naming_required` | The meeting is at `NAMING`; sent again each time naming opens again | `status` | | `recording.ready` | The meeting's Lobbe was made for the first time | `status` | | `recording.failed` | The meeting failed for good | `status`, `error: { code, retryable }` | | `recording.updated` | A `READY` meeting changed, and `data.change` says how | `status`, `change` | | `recording.deleted` | The meeting was deleted, at any status | `status` | `data.change` is one of `regenerated` (the Lobbe was made again), `renamed`, `transcript_edited`, `speakers_renamed`, `series_changed` (moved into, out of or between series) and `restored` (a deleted meeting is back, at whatever status it had). - **No event carries a meeting's content:** only ids, its status, an error code or the kind of change. To read the meeting, fetch its bundle. - **The steps in between send nothing:** `QUEUED`, `TRANSCRIBING` and `GENERATING` have no event. Neither has a retry: the meeting's next event follows. - **`recording.failed` is final:** a failure Lobbe is still retrying by itself is never sent. - **A regeneration is `recording.updated`** with `regenerated`, never a second `recording.ready`. - **Changes to a series itself send nothing** for its meetings: renaming it, changing how it titles meetings, or deleting it. ### Whose meetings - **With `recordings:read`,** the feed has every meeting of the account, whoever sent it: the app, an upload, this token or another. It doesn't have the meetings of a series the owner joined. - **With `recordings:write` and no `recordings:read`,** it has only the meetings this token sent. While sending meetings through a token isn't on for the account, such a token gets `403 SCOPE_DISABLED`. - **`seriesId`** is the series the meeting was in when the change happened: `null` when it was in none, or while series are switched off. ### Reading it 1. **Start from now** with `after=latest`: no events, and a cursor to read on from. Without `after`, the feed starts from the oldest event it still keeps, about 30 days back. 2. **Keep `nextCursor`.** It is always there. Send it back as `after` on the next read, exactly as you got it: never build or change one, and never use an event's `id` as one. 3. **Read page by page.** A page has up to `limit` events (1–500, 100 by default). When a page comes back full, read the next one at once; otherwise wait about a minute. 4. **Store the cursor after handling the page,** never before. A loop that does all four, and stops on any error, printing it: ```bash cursor=$(cat lobbe-cursor 2>/dev/null || echo latest) while true; do body=$(curl -sS --fail-with-body "https://app.lobbe.ai/api/v1/events?after=$cursor&limit=100" \ -H "Authorization: Bearer $LOBBE_TOKEN") || { echo "$body"; break; } jq -c '.events[]' <<<"$body" # handle each event here cursor=$(jq -er .nextCursor <<<"$body") || { echo "$body"; break; } echo "$cursor" >lobbe-cursor [ "$(jq '.events | length' <<<"$body")" -eq 100 ] || sleep 60 done ``` A consumer that runs for good waits `Retry-After` on a `429` instead of stopping, and recovers from a `410` as below. Reading the feed draws on the same `read` budget as status, bundles and the list. ### At least once The feed gives you every event at least once, not exactly once. If your process stops after handling a page but before storing its cursor, the next read gives you those events again. - **Skip what you've handled, by event `id`.** It is the same on every read. - **Don't de-duplicate by meeting and type.** One meeting can have several events of one type: naming can open twice, and a meeting can be renamed many times. Each is its own event, with its own `id`. - **Follow the feed's order,** not `occurredAt`. For one meeting, events come in the order its changes happened. An event can arrive a little after its change, but none is skipped. ### 30 days, then `410` - Lobbe keeps events for 30 days. - A cursor ages with the last event it read, or with the read itself once you've caught up. So a consumer that keeps reading never ages out, even on an account where nothing happens. - A cursor older than that (one left unused for a month, say) answers `410 CURSOR_EXPIRED`. So does a cursor from before a restore of Lobbe's data. To recover from a `410`, or to start on an account that already has meetings: 1. **Take a new cursor** with `after=latest`. 2. **Catch up.** With `recordings:read`, page through [`GET /recordings/ready`](/reference/recordings/#listReadyRecordings) and add the finished meetings you don't have yet. The list holds `READY` meetings only, so a meeting that failed for good or was deleted in the gap isn't in it. For a meeting you hold that the list no longer has, ask its status: `404` means it was deleted; otherwise the status says where it is now (a regeneration in progress, say). For a meeting you were still waiting on, its status says whether it failed. A token with `recordings:write` alone has no list: ask the status of each meeting it sent that you are still waiting on. 3. **Read on** from the new cursor. In that order, a meeting that changes while you catch up shows up in the feed too. You may meet it twice, but you never miss it. :::note Sending meetings, creating and editing series, changing settings, search and Ask through a token are being switched on gradually. If the form in Connections doesn't offer a scope, it isn't available to your account yet. ::: --- # Search and Ask > Find meetings by their words, ask one meeting or a whole series a question, and read the answer's footnotes. Two scopes open these routes: - **`recordings:query`** (**Meetings**, **Search and ask**): search your meetings, and ask one meeting a question. - **`series:query`** (**Series**, **Ask a series**): ask a whole series a question. Searching is free, and so is asking what a question will cost. Each answer spends minutes. ## Search [`GET /recordings/search`](/reference/recordings/#searchRecordings) finds finished meetings by their words: ```bash curl -G https://app.lobbe.ai/api/v1/recordings/search \ -H "Authorization: Bearer $LOBBE_TOKEN" \ --data-urlencode "q=launch budget" \ --data-urlencode "from=2026-10-01T00:00:00+03:30" ``` `-G` with `--data-urlencode` puts the parameters in the address, encoded, so a Persian `q` works as it is. ### What matches - **Every word of `q` must appear** somewhere in the meeting: its title, its transcript or its Lobbe. A word also matches inside a longer word. - **Small differences don't count.** Capitals, diacritics and the half-space are ignored, Arabic letter forms (`ي`, `ك`) match Persian ones, and Persian digits match Latin ones. - **`q` is required**, 1 to 200 characters. A `q` with no word in it, only spaces for example, answers `400 VALIDATION`. ### Which meetings - **Without `seriesId`:** your own finished meetings, the ones in `READY`. - **With `seriesId`:** the finished meetings of that series, a series you joined included. A series you can't read answers `404`. ### Filters and order | Parameter | What it does | | --- | --- | | `seriesId` | Only the meetings of this series | | `kind` | Only meetings of this kind: `MEETING`, `LECTURE`, `INTERVIEW`, `VOICE_MEMO` or `CALL` | | `from`, `to` | Only meetings that happened in this window (`recordedAt`, both ends included), ISO 8601 with an offset | | `limit` | How many meetings per page, 1–50 (20 by default) | | `cursor` | The `nextCursor` of the previous page, passed back as it is | - Any other parameter answers `400 VALIDATION`, so a typo never turns into a filter that silently does nothing. - **The newest meeting comes first, by when it happened** (`recordedAt`). This is not the order of [`GET /recordings/ready`](/reference/recordings/#listReadyRecordings), which follows when Lobbe received each meeting. See [The meeting time](/guides/lifecycle/#the-meeting-time). - `nextCursor` is `null` on the last page. ### What comes back ```json { "schemaVersion": 1, "items": [ { "id": "MEETING_ID", "title": "Weekly product sync", "kind": "MEETING", "createdAt": "2026-10-19T08:31:12.000Z", "recordedAt": "2026-10-19T07:00:00.000Z", "durationSeconds": 2520, "url": "https://app.lobbe.ai/m/MEETING_ID", "bundleUrl": "https://app.lobbe.ai/api/v1/recordings/MEETING_ID/bundle", "series": { "id": "SERIES_ID", "name": "Product sync" }, "displayTitle": "Weekly product sync", "snippet": "…so the launch budget stays where it is until the payment fix ships…" } ], "nextCursor": null } ``` Each item is an item of the list of finished meetings, plus `snippet`: up to 200 characters around the first match, from the transcript, or from the Lobbe when only the Lobbe matched. It is `null` when the words were found in the title alone. ### What `recordings:query` reaches - A token with `recordings:query` and no `recordings:read` has no list and no bundles. Each item still carries `bundleUrl`, but fetching it needs `recordings:read`. - Search is not a way to hide meetings from such a token: a short `q` matches nearly every meeting, so it can find any of your finished meetings, and then ask it. - Search spends the token's `search` budget, shared with the Ask quotes: its size is in [Rate limits](/start/limits/#rate-limits). ## Ask a meeting Asking is two steps: get the price, then ask with that price as your ceiling. ### 1. Get the price ```bash curl https://app.lobbe.ai/api/v1/recordings/MEETING_ID/ask/quote \ -H "Authorization: Bearer $LOBBE_TOKEN" ``` ```json { "schemaVersion": 1, "minutes": 3 } ``` [`GET /recordings/{id}/ask/quote`](/reference/recordings/#quoteRecordingAsk) is free. The price depends on the meeting, not on the question: it comes from the length of the transcript as Lobbe reads it, the speakers' names included. So one quote holds for every question to that meeting, until its transcript is edited or a speaker is named or renamed. ### 2. Ask ```bash key=$(uuidgen) # one key for this question, kept for every retry of it curl https://app.lobbe.ai/api/v1/recordings/MEETING_ID/ask \ -H "Authorization: Bearer $LOBBE_TOKEN" \ -H "Idempotency-Key: $key" \ -H "Content-Type: application/json" \ -d '{ "question": "What did we decide about the launch date?", "maxMinutes": 3 }' ``` - **`question`** is 1 to 500 characters, after spaces at either end are trimmed. - **`maxMinutes`** is optional: the most you agree to pay. Without it, the Ask charges whatever the price is. With it, a higher price answers `409 PRICE_ABOVE_MAX`, with `details.minutes` (the price) and `details.maxMinutes`, and nothing is charged. Send the quote's `minutes`: then a price that changed since the quote can never charge you more than you saw. - **`Idempotency-Key`** makes a retry safe: the same key gets the first answer back, never a second charge. See [Idempotency and retries](/guides/idempotency/). [`POST /recordings/{id}/ask`](/reference/recordings/#askRecording) answers `200`: ```json { "schemaVersion": 1, "answer": "The launch moves to 2 November [1], once the payment fix has been tested [2].", "citations": [ { "startMs": 754000, "speaker": "speaker_1" }, { "startMs": 812500, "speaker": "speaker_0" } ], "minutes": 3, "refused": false } ``` `minutes` is what this answer charged. ### Footnotes and citations - **`[n]` in `answer` marks the claim that `citations[n - 1]` supports.** The markers are numbered 1, 2, 3… in reading order, and `citations` lists the turns in the order the answer first cites them. A group such as `[1][2]` cites two turns. - **A meeting's answer cites at most 3 turns.** An answer can cite none, and then has no markers: a question about how long the meeting was, say, is answered from its measured facts. - **Each citation is a turn of the transcript.** `startMs` is when the turn starts, in milliseconds from the start of the audio. `speaker` is the voice's label as transcribed (`speaker_0`), never the name someone gave it. - **To show a cited turn,** find the segment in the meeting's [bundle](/reference/recordings/#getRecordingBundle) whose `startMs` is the same. Take who spoke from that segment's `speaker`, which the bundle has already matched to a person in `transcript.speakers`, with their name. This needs `recordings:read`. - **Lobbe checks the citations.** A citation that points at no real turn is dropped, and an answer whose every citation is invented is not given at all: it answers `502 GENERATE_FAILED`, and nothing is charged. ### When Lobbe declines When the meeting doesn't answer the question, `refused` is `true` and `answer` says why. A refusal is charged, like any answer: Lobbe had to read the whole meeting to know. ### What else to know - **It is your Ask.** It shows in the meeting's **Ask** tab in the app. Lobbe also reads the latest questions and answers on the meeting as context for the next one, whether they were asked in the app or through a token. - **Which meetings:** your own, and those of a series you joined. A meeting that isn't `READY` answers `409 ASK_NOT_READY`, and one you can't reach answers `404`. - **Spending:** the price comes from your balance (`402 INSUFFICIENT_MINUTES` when it is short) and counts toward the token's daily cap (`429 TOKEN_DAILY_CAP`). Lobbe's own daily limit on Asks, one limit for meeting and series Asks together, answers `429 DAILY_LIMIT_REACHED` with `Retry-After`. See [Minutes, caps and limits](/start/limits/). - **Budget:** the Asks share the token's `ask` budget: its size is in [Rate limits](/start/limits/#rate-limits). - While Ask is switched off in Lobbe, an Ask or a quote answers `409 ASK_DISABLED`: try again later. ## Ask a series With `series:query`, one question reads every finished meeting of a series: one you own, or one you joined. ```bash curl https://app.lobbe.ai/api/v1/series/SERIES_ID/ask/quote \ -H "Authorization: Bearer $LOBBE_TOKEN" ``` ```json { "schemaVersion": 1, "minutes": 9, "meetingCount": 6 } ``` Then ask as you would a meeting, with the same body and an `Idempotency-Key`: ```bash key=$(uuidgen) curl https://app.lobbe.ai/api/v1/series/SERIES_ID/ask \ -H "Authorization: Bearer $LOBBE_TOKEN" \ -H "Idempotency-Key: $key" \ -H "Content-Type: application/json" \ -d '{ "question": "Which action items are still open?", "maxMinutes": 9 }' ``` ```json { "schemaVersion": 1, "answer": "Two are still open: the pricing page copy [1] and the Android release notes [2].", "citations": [ { "recordingId": "MEETING_ID", "startMs": 1203000, "speaker": "speaker_2" }, { "recordingId": "MEETING_ID_2", "startMs": 98000, "speaker": "speaker_0" } ], "minutes": 9, "refused": false } ``` - **What it reads:** [`GET /series/{id}/ask/quote`](/reference/series/#quoteSeriesAsk) gives the price and `meetingCount`, how many finished meetings the question reads. It reads every one's Lobbe, then as many full transcripts as fit, newest first. - **Too few meetings:** a series needs at least 2 finished meetings. With fewer, the quote and the Ask both answer `409 SERIES_ASK_TOO_FEW`. With one, ask that meeting instead. - **Citations name a meeting too:** each carries `recordingId` beside `startMs` and `speaker`, and an answer cites at most 5 turns, from any of the series' meetings. To show one, read that meeting's bundle. - **Footnotes, refusals, `maxMinutes`, the price, the balance, the caps and the key** work as for a meeting, through [`POST /series/{id}/ask`](/reference/series/#askSeries). The price comes from the series' meetings, so it changes as meetings are added or change. - **It is your Ask,** in the series' Ask in the app. A member who asks pays from their own balance, never the series' owner. - A series you can't reach answers `404`, and so does every series while series are switched off. While Ask is switched off, the Ask answers `409 ASK_DISABLED` and the quote answers `404`. :::note Sending meetings, creating and editing series, changing settings, search and Ask through a token are being switched on gradually. If the form in Connections doesn't offer a scope, it isn't available to your account yet. ::: --- # Series > Read the series you own or joined, list their meetings, create and change series, and file a new meeting in one. A series groups the meetings that belong together, a weekly team sync for example. A token reaches two kinds: - **the series you own:** you made them, and you can change them; - **the series you joined:** someone else's series that you were invited to. You read their finished meetings, but the series stays its owner's. While series are switched off in Lobbe, `GET /series/readable` answers an empty list, the other series routes and `?seriesId=` answer `404`, and a meeting sent with `seriesId` goes to your **Personal archive**. ## Reading series With `series:read` (**Series**, **Read**): | Route | What it gives | | --- | --- | | [`GET /series/readable`](/reference/series/#listReadableSeries) | Your series: the ones you own, then the ones you joined, each group by name | | [`GET /series/{id}`](/reference/series/#getSeries) | One series: its name, its defaults, how it titles meetings, and its finished meetings' count and newest time | | [`GET /series/{id}/keyterms`](/reference/series/#getSeriesKeyterms) | Its vocabulary. Owner only | ```bash curl https://app.lobbe.ai/api/v1/series/readable \ -H "Authorization: Bearer $LOBBE_TOKEN" ``` ```json { "schemaVersion": 1, "items": [ { "id": "SERIES_ID", "name": "Product sync", "role": "owner", "meetingCount": 6, "url": "https://app.lobbe.ai/series/SERIES_ID", "meetingsUrl": "https://app.lobbe.ai/api/v1/recordings/ready?seriesId=SERIES_ID" } ] } ``` - **`role`** in the list, and **`access`** in `GET /series/{id}`, say `owner` or `member`. - **`meetingCount`** is how many finished meetings of the series you can read now: exactly the ones its `meetingsUrl` lists. The app counts every meeting of the series, finished or not, so its number can be higher. - **What a token never sees of a series:** its members, its invite and its email list. The vocabulary comes only from `GET /series/{id}/keyterms`, and that one never includes your account's own vocabulary, which is `account:read`'s. - An unknown series, someone else's, one you were removed from, or one you joined while Lobbe has series members switched off: each answers `404`, one answer for all. ## A series' meetings The meetings themselves are read with `recordings:read`, not `series:read`. Add `seriesId` to the list of finished meetings, [`GET /recordings/ready`](/reference/recordings/#listReadyRecordings), which is what `meetingsUrl` already does: ```bash curl "https://app.lobbe.ai/api/v1/recordings/ready?seriesId=SERIES_ID" \ -H "Authorization: Bearer $LOBBE_TOKEN" ``` - **A series you joined works too.** Its meetings' bundles say `recording.access: "member"`. - **Without `seriesId`,** the list has your own meetings only, never the meetings of a series you joined. So a script that copies "new meetings" doesn't start copying a colleague's meetings by itself. - A series you can't read answers `404`, never an empty page. - [Search](/guides/search-and-ask/#search) takes the same `seriesId`. ## Creating and changing series With `series:write` (**Series**, **Create and edit**): ```bash curl https://app.lobbe.ai/api/v1/series \ -H "Authorization: Bearer $LOBBE_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Product sync" }' ``` [`POST /series`](/reference/series/#createSeries) answers `201` with the new series, in the same shape as `GET /series/{id}`. Only `name` is required: | Field | Default | What it is | | --- | --- | --- | | `name` | — | The series' name | | `defaultKind` | `MEETING` | The kind the app suggests for a meeting filed in the series | | `defaultOutputLanguage` | `MATCH` | The Lobbe language the app suggests for it | | `titleStyle` | `SESSION` | `SESSION` titles each meeting with the series' name and its date, its own title beneath; `TOPIC` keeps each meeting's own title | | `dateCalendar` | `AUTO` | The calendar of that date: `AUTO` follows the Lobbe's language, or `JALALI`, or `GREGORIAN` | `defaultKind` and `defaultOutputLanguage` are the app's starting values. A meeting you send with a token doesn't read them: send `kind` and `outputLanguage` with it. ### The name - **A name is one line.** Runs of whitespace, line breaks and tabs included, become one space, and the ends are trimmed. Then it must be 1 to 60 characters. Any other control character answers `400 VALIDATION`. - **Names are unique per account, ignoring capitals.** A name one of your series already has answers `409 SERIES_NAME_TAKEN`. - An account can own up to 50 series. One more answers `409 SERIES_LIMIT`. ### Changing a series - [`PATCH /series/{id}`](/reference/series/#updateSeries) takes the same fields, at least one. A field you leave out keeps its value, and an empty body answers `400 VALIDATION`. - [`PUT /series/{id}/keyterms`](/reference/series/#replaceSeriesKeyterms) replaces the series' vocabulary. It takes the whole list, `keyterms`, and `inheritAccountKeyterms`, whether your account's vocabulary applies too (the app's **Also use my vocabulary**). Both are required. The list follows the vocabulary rules, the same as the account's: see [Vocabulary](/guides/account/#vocabulary). - A meeting filed in the series is transcribed with the series' words first, then, when `inheritAccountKeyterms` is `true`, with your account's, up to 100 terms in all. A meeting that uses vocabulary costs more: see [What spends](/start/limits/#what-spends). A meeting keeps the words it was sent with, so a new list changes only later meetings. ### Owner only `PATCH /series/{id}` and both vocabulary routes are for the series' owner. A member's token gets `404` on them, the same answer as a stranger's: a series that isn't yours is a `404`, never a `403`. `GET /series/{id}` answers members as well. ## Filing a meeting in a series A new meeting goes into a series when you send it, with `seriesId`: ```bash curl https://app.lobbe.ai/api/v1/recordings \ -H "Authorization: Bearer $LOBBE_TOKEN" \ -F "audio=@meeting.m4a;type=audio/mp4" \ -F seriesId=SERIES_ID ``` - **Only a series you own.** A series you joined, or an id Lobbe doesn't know, is not an error: the meeting goes to your **Personal archive**, the meetings in no series. To check where it went, read `recording.series` in its bundle, or `seriesId` on its events. - **The series' vocabulary applies,** as above. Send `-F useKeyterms=off` to transcribe this meeting without any vocabulary, and without its surcharge. - **A token can't move a meeting it already sent,** into a series, out of one or between two. That is done in the app. A token gets `401 TOKEN_NOT_ACCEPTED` on the app's own routes for it. What a token can never do with series, deleting one or inviting members among them, is in [Tokens and scopes](/start/tokens/#what-no-token-can-do). :::note Sending meetings, creating and editing series, changing settings, search and Ask through a token are being switched on gradually. If the form in Connections doesn't offer a scope, it isn't available to your account yet. ::: --- # Account, vocabulary and memory > Read the balance and settings, change three settings, keep the vocabulary, and look after Lobbe's memory. Two scopes act on the account the token belongs to: - **`account:read`** (**Account**, **See balance and settings**): the balance, the settings, the vocabulary and Lobbe's memory. - **`account:write`** (**Account**, **Change settings**): three settings, the vocabulary, and editing or deleting memory notes. ## The account [`GET /account`](/reference/account/#getAccount): ```bash curl https://app.lobbe.ai/api/v1/account \ -H "Authorization: Bearer $LOBBE_TOKEN" ``` ```json { "schemaVersion": 1, "owner": { "type": "user", "id": "USER_ID", "displayName": "Sara" }, "wallet": { "balanceMinutes": 312 }, "settings": { "speakerNamingMode": "FORCE_ON", "memoryLearningEnabled": true, "summaryEmailEnabled": false, "uiLocale": "fa" } } ``` - **Never the phone number or the email address.** `displayName` is the owner's name, or `null` when no name was set: a phone number is never sent in its place. - **`wallet.balanceMinutes`** is the balance in minutes. It doesn't take out meetings whose transcript isn't ready yet: see [Running short](/start/limits/#running-short). - **`settings.uiLocale`** is the app's language. A token reads it but can't change it. ## Settings [`PATCH /account/settings`](/reference/account/#updateAccountSettings) changes three settings, the same ones as in the app's **Account**: | Field | In the app | Values | | --- | --- | --- | | `speakerNamingMode` | **Name speakers** | `FORCE_ON` (**Ask**), `AUTO` (**Ask, but don't wait**) or `FORCE_OFF` (**Auto-guess**): see [Speaker naming](/guides/lifecycle/#speaker-naming) | | `memoryLearningEnabled` | **Learn from new meetings**, in **Lobbe's memory** | `true` or `false`. `false` stops the learning only: the notes already kept are still used | | `summaryEmailEnabled` | **Email me after each meeting** | `true` or `false`. Each new Lobbe is emailed to the owner with its PDF, never again when it is made again, and never while the account has no email address (only the app sets one) | ```bash curl -X PATCH https://app.lobbe.ai/api/v1/account/settings \ -H "Authorization: Bearer $LOBBE_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "speakerNamingMode": "AUTO" }' ``` - **Send one, two or all three.** A field you leave out keeps its value. - **Nothing else.** A body with any other field, `uiLocale` included, answers `400 VALIDATION` and nothing is written, not even the fields that were right. So does an empty body. - The answer is the whole account, as `GET /account` gives it. - A meeting reads the naming setting when its transcript is ready, so a change applies to the meetings whose transcript is ready after it. Everything else about the account (the name, phone number, email, password, sign-in sessions, **Delete audio after the Lobbe**, the language) stays in the app: see [What no token can do](/start/tokens/#what-no-token-can-do). ## Vocabulary The vocabulary (**My vocabulary** in the app) is the names and terms transcription should recognize: up to 100 of them. - [`GET /account/keyterms`](/reference/account/#getAccountKeyterms) reads it, as `{ "schemaVersion": 1, "keyterms": […] }`. - [`PUT /account/keyterms`](/reference/account/#replaceAccountKeyterms) replaces it: send the whole new list. To add one term, read the list, add it, and write the list back. ```bash rm -f keyterms.json new.json curl -sS --fail-with-body https://app.lobbe.ai/api/v1/account/keyterms \ -H "Authorization: Bearer $LOBBE_TOKEN" -o keyterms.json || cat keyterms.json jq -e '{ keyterms: ((.keyterms | arrays) + ["Kubernetes"]) }' keyterms.json >new.json && curl -sS --fail-with-body -X PUT https://app.lobbe.ai/api/v1/account/keyterms \ -H "Authorization: Bearer $LOBBE_TOKEN" \ -H "Content-Type: application/json" \ -d @new.json ``` If the read fails, `jq -e` finds no list and the write never runs: a failed read can't wipe the list. ### The rules - Each term is trimmed, and its runs of spaces become one. - Then: at most 100 terms; each 1 to 49 characters and at most 5 words; none containing `<`, `>`, `{`, `}`, `[`, `]` or `\`; and no term twice. - A list that breaks a rule answers `400 VALIDATION` and nothing is written. `message` names the first problem: `empty`, `too_long`, `too_many_words`, `forbidden_chars` or `duplicate`, or, for more than 100 terms, says so in words. ### Where it is used - A meeting in no series is transcribed with the account's vocabulary. - A meeting in a series uses the series' own words first, then the account's when the series has `inheritAccountKeyterms: true`, up to 100 terms in all: see [Series](/guides/series/#changing-a-series). - Each meeting keeps the words it was sent with, so a new list changes only later meetings. - **A meeting that uses vocabulary costs more.** Writing the list spends nothing, but each later meeting that uses it carries the vocabulary surcharge: see [What spends](/start/limits/#what-spends). A meeting sent with `useKeyterms=off` uses none, and pays none. ## Memory Lobbe learns a few things from your meetings, about you, the people you meet with and your terms, and keeps them as notes (**Lobbe's memory** in the app). It reads them as background when it writes a Lobbe and when it answers an Ask, so they come out more accurate. ### Reading it [`GET /account/memory`](/reference/account/#listMemory): ```json { "schemaVersion": 1, "memoryEnabled": true, "items": [ { "id": "ITEM_ID", "category": "PEOPLE", "text": "Reza leads the Android team.", "meetingCount": 4, "pinned": false } ] } ``` - **`category`** is the note's section: `ABOUT_YOU` (**About you**), `PEOPLE` (**People you meet with**) or `TERMS` (**Your terminology**). - **`meetingCount`** is how many meetings it was learned from. - **`pinned`** is `true` once the owner, or a token, edited it. - While memory is switched off in Lobbe, `memoryEnabled` is `false` and `items` is empty. ### Editing and deleting - [`PATCH /account/memory/{id}`](/reference/account/#updateMemoryItem) with `{ "text": "…" }` rewrites one note and answers `{ "schemaVersion": 1, "item": … }`. The text is trimmed, and must be at least 1 character and within the app's limit for a note: longer answers `400 VALIDATION`. A text another note in the same section already has answers `409 MEMORY_DUPLICATE`. - [`DELETE /account/memory/{id}`](/reference/account/#deleteMemoryItem) deletes one note for good and answers `{ "schemaVersion": 1, "id": "ITEM_ID", "deleted": true }`. A later meeting may teach it again. - An id that isn't one of your notes answers `404`. - While memory is switched off, both answer `409 MEMORY_DISABLED`: a write never pretends to work. ```bash curl -X DELETE https://app.lobbe.ai/api/v1/account/memory/ITEM_ID \ -H "Authorization: Bearer $LOBBE_TOKEN" ``` :::caution[An edited note is pinned, and goes into everything] Editing a note pins it. From then on Lobbe never rewrites or removes it, and it goes into **every Lobbe and every Ask** made for you, until someone deletes it. So a token with `account:write` can put any text, up to the note limit, into everything Lobbe writes for you. Give `account:write` only to a tool you trust. Every change a token makes shows in its activity in **Connections**, and you can read and delete every note in the app: **Account**, then **Lobbe's memory**. ::: To stop Lobbe learning new notes, set `memoryLearningEnabled` to `false`. That doesn't stop the notes already kept from being used: to stop one, delete it. :::note Sending meetings, creating and editing series, changing settings, search and Ask through a token are being switched on gradually. If the form in Connections doesn't offer a scope, it isn't available to your account yet. ::: --- # Pull meetings into your own tools > Copy each finished meeting into your notes, your wiki or an agent's inbox, then keep the copy up to date from the events feed. A token with `recordings:read` (**Meetings**, **Read**) can copy every finished meeting out of Lobbe: into a folder of Markdown notes, a wiki, a search index, or the inbox an agent works through. This page walks through that pull, then keeps it up to date. ## Walk the list, fetch each meeting 1. **Page through the finished meetings** with [`GET /recordings/ready`](/reference/recordings/#listReadyRecordings): newest first, up to `limit` (1–100, 20 by default) per page. Send each page's `nextCursor` back as `cursor`, exactly as you got it; it is `null` on the last page. 2. **For each meeting you don't have yet,** fetch the whole meeting from its `bundleUrl`: JSON, or one Markdown document with `?format=markdown`. 3. **Keep it under its `id`.** The id never changes. ```json { "schemaVersion": 1, "items": [ { "id": "MEETING_ID", "title": "Weekly product sync", "kind": "MEETING", "createdAt": "2026-10-19T08:31:12.000Z", "recordedAt": "2026-10-19T07:00:00.000Z", "durationSeconds": 2520, "url": "https://app.lobbe.ai/m/MEETING_ID", "bundleUrl": "https://app.lobbe.ai/api/v1/recordings/MEETING_ID/bundle", "series": null, "displayTitle": "Weekly product sync" } ], "nextCursor": "1792398672000.MEETING_ID" } ``` Three things to know about the list: - **Walk all of it, every time.** Don't stop at the first meeting you already have. The list is in the order Lobbe received the meetings, and a meeting joins it only once it is finished: a long meeting sent at 10:00 can finish after a short one sent at 10:05, and then sits below it. - **The cursor holds its place.** It continues after the last meeting of the page, so a meeting that leaves the list between two pages never makes you miss the next one. - **It has your own meetings only.** A series you joined isn't in it: walk `GET /recordings/ready?seriesId=SERIES_ID` for each of those (see [Series](/guides/series/#a-series-meetings)). The list can't tell you that a meeting you copied has changed (its Lobbe made again, a new title, an edited transcript, renamed speakers, another series) or was deleted. The events feed can. ## Keep up with the feed Instead of walking the whole list again and again, follow [`GET /events`](/reference/events/#listEvents) (the [events feed](/guides/polling-and-events/#the-events-feed)). It covers the same meetings as the list without `seriesId`, your own, at every status. 1. **Take a cursor first,** with `GET /events?after=latest`, and keep it. 2. **Then walk the list once,** as above, for everything that is already there. 3. **Then read the feed from that cursor.** Anything that changed during the walk is in it, so you never miss a meeting. You may meet one twice, which is harmless. What to do with each event: | Event | What to do | | --- | --- | | `recording.ready` | Fetch its bundle | | `recording.updated` | Fetch its bundle again: `data.change` says what changed | | `recording.deleted` | Delete your copy | | `recording.created`, `recording.naming_required`, `recording.failed` | Nothing: the meeting isn't finished | - **Fetch when `data.status` is `READY`.** A deleted meeting that is restored comes back as `recording.updated` with `restored`, at the status it had: fetch it if that is `READY`. - **A bundle can answer `409 NOT_READY`** when the meeting is being made again by the time you fetch it, and `404` when it was deleted since. Skip it: a later event says what happened. - **Fetching twice is harmless.** The feed gives each event at least once: see [At least once](/guides/polling-and-events/#at-least-once). - **A cursor left unused for 30 days** answers `410 CURSOR_EXPIRED`: start again from step 1. The walk skips the meetings you already have, so one that changed while you were away keeps its old copy. To be sure, empty your copy and fetch everything again. ## A script that does all of it This script copies every finished meeting into `./meetings`, one Markdown file per meeting, then follows the feed for good. It needs `bash`, `curl` and `jq`. Run it again after a stop and it goes on from the cursor it stored, even when the stop came during its first walk; an answer it doesn't expect stops it, printed. ```bash #!/usr/bin/env bash # Copy every finished meeting into ./meetings as Markdown, then keep up with the feed. api=https://app.lobbe.ai/api/v1 mkdir -p meetings # GET $1 into the file $2 and print the HTTP status (000: no answer). Waits Retry-After on a 429. get() { local code wait while true; do rm -f "$2" headers.txt code=$(curl -sS -o "$2" -D headers.txt -w '%{http_code}' "$1" \ -H "Authorization: Bearer $LOBBE_TOKEN") || code=000 wait=$(awk -F': *' 'tolower($1) == "retry-after" { print $2 + 0 }' headers.txt 2>/dev/null) if [ "$code" = 429 ] && [ -n "$wait" ]; then sleep "$wait"; continue; fi echo "$code" return done } # One meeting's Markdown. 404 (deleted) and 409 (being made again) are skipped: # a later event says what happened. Anything else stops the script. fetch() { case "$(get "$api/recordings/$1/bundle?format=markdown" meeting.tmp)" in 200) mv meeting.tmp "meetings/$1.md" ;; 404 | 409) rm -f meeting.tmp ;; *) cat meeting.tmp 2>/dev/null; exit 1 ;; esac } # The first run: a cursor from now, THEN the whole list, so nothing that changes meanwhile is missed. # A run stopped during the walk keeps that first cursor, so the feed still covers what changed since. if [ ! -s meetings/.cursor ]; then if [ ! -s meetings/.start-cursor ]; then [ "$(get "$api/events?after=latest" page.json)" = 200 ] || { cat page.json 2>/dev/null; exit 1; } jq -er .nextCursor page.json >next.cursor && mv next.cursor meetings/.start-cursor || exit 1 fi cursor="" while true; do [ "$(get "$api/recordings/ready?limit=100${cursor:+&cursor=$cursor}" page.json)" = 200 ] || { cat page.json 2>/dev/null; exit 1; } for id in $(jq -r '.items[].id' page.json); do [ -f "meetings/$id.md" ] || fetch "$id" done cursor=$(jq -r '.nextCursor // empty' page.json) [ -n "$cursor" ] || break done mv meetings/.start-cursor meetings/.cursor fi # Then the feed, page by page, storing the cursor only once a page is handled. while true; do [ "$(get "$api/events?after=$(cat meetings/.cursor)&limit=100" page.json)" = 200 ] || { cat page.json 2>/dev/null; exit 1; } jq -r '.events[] | "\(.type) \(.recordingId) \(.data.status)"' page.json >events.txt while read -r type id status; do if [ "$type" = recording.deleted ]; then rm -f "meetings/$id.md" elif [ "$status" = READY ]; then fetch "$id" fi done next.cursor && mv next.cursor meetings/.cursor || exit 1 [ "$(jq '.events | length' page.json)" -eq 100 ] || sleep 60 done ``` - **After a `410 CURSOR_EXPIRED`:** delete `meetings/.cursor` and `meetings/.start-cursor`, and run it again. It takes a new cursor and walks the list again, fetching only the meetings it doesn't have. - **Its own files:** the two cursors live in `meetings/`, beside the copies. It also leaves `page.json`, `events.txt` and `headers.txt` in the directory you run it from, and `meeting.tmp` after a fetch that stopped it. - **JSON instead of Markdown:** drop `?format=markdown` and name the files `.json`. - **Rate limits:** the list, the bundles and the feed share the token's `read` budget: its size is in [Rate limits](/start/limits/#rate-limits). A large first pull can use it up; `get` then waits the seconds `Retry-After` gives. - **One token, one job:** this token needs `recordings:read` only. It never spends minutes. ## What a bundle holds [`GET /recordings/{id}/bundle`](/reference/recordings/#getRecordingBundle) answers one finished meeting. As JSON it has four parts: | Part | What it holds | | --- | --- | | `recording` | The meeting: its id, its titles (`title`, `displayTitle`, `subtitle`), `kind`, `source`, when it happened (`recordedAt`) and when Lobbe received it (`createdAt`), its length, its `url` in the app, its `series`, and `access`: `owner`, or `member` for a series you joined | | `transcript` | What was said: the language, the people (`speakers`, with the names given to them), the turns (`segments`, each with its speaker, times and text), the whole text, and the transcript as Markdown | | `lobbe` | The Lobbe: its template (`type`), its language, its `content` as sections, and the whole Lobbe as Markdown | | `stats` | The meeting's numbers, counted from the transcript: speaking time, turns, questions, per person | The Lobbe's sections come in reading order, and each one's `kind` says what fields it has: `summary`, `decision_list`, `action_item_list` and so on. The reference lists every kind's fields. With `?format=markdown`, the same meeting is one `text/markdown` document: the title as a heading, the meeting's details (when, how long, the series, who spoke), the Lobbe's sections, then the transcript. It is made to drop into a note or a prompt as it is. Its reach and its errors are the JSON bundle's: `404` for a meeting you can't read, `409 NOT_READY` for one that isn't finished. ### Read it leniently - **The bundle is stable within v1.** `schemaVersion` stays `1`, and changes within v1 only add: a field is never removed or renamed. - **So ignore what you don't know.** A field you don't recognize may appear in any part: skip it, never fail on it. The same goes for a Lobbe section whose `kind` you don't know. - **Key your copy on `recording.id`,** and file it by `recordedAt`, when the meeting happened. --- # A submit-only flow (n8n) > A workflow that sends meetings to Lobbe and follows them, with a token that can't read any meeting's transcript or Lobbe. Some tools only need to hand meetings to Lobbe. An n8n workflow, say, picks up a recording, a file someone shares in Slack or the recording of an online meeting, and sends it. The person then reads the Lobbe in the app; the workflow never has to. Give such a tool a token with **Send meetings** alone: `recordings:write`, without `recordings:read`. If it ever leaks, it can spend minutes, but it can't read any meeting's transcript or Lobbe. Like every token, it works only while **Developer mode** is on. While it is off, every request answers `403 DEVELOPER_MODE_OFF` until it is turned back on: see [Tokens and scopes](/start/tokens/#developer-mode). A workflow that runs for months should tell the person when it meets that code, not retry it in a loop. ## What the token can do - **Send meetings:** [`POST /recordings`](/reference/recordings/#submitRecording). Each one spends the meeting's minutes, so give the token a [daily cap](/start/tokens/#the-daily-cap). - **Follow the meetings it sent:** their status, [`GET /recordings/{id}/status`](/reference/recordings/#getRecordingStatus), and their events in [`GET /events`](/reference/events/#listEvents), where it sees only the meetings it sent. - **Act on the meetings it sent:** skip naming and retry a failed meeting. See [The meeting lifecycle](/guides/lifecycle/). - **Check itself** with [`GET /whoami`](/reference/token/#whoami), which answers every token. ## What it can't - **Any other meeting:** one recorded in the app, or sent by another token, answers `404`, as if it didn't exist. - **The transcript and the Lobbe of any meeting,** its own included: the status of a meeting it sent carries the title, and nothing more. The bundle and the list of finished meetings need `recordings:read`, and search and Ask need `recordings:query`: each answers `403 TOKEN_SCOPE`. - **`bundleUrl`** in its answers is always `null`. So the workflow passes on links, never the meeting itself: `naming.url` while the meeting waits for names, then `url` once it is ready. Neither is a public link: each opens the meeting in the app, and only for someone signed in who can already see it. ## The flow 1. **Send the recording,** with a new `Idempotency-Key` for each file (kept for every retry of it: see [Idempotency and retries](/guides/idempotency/)). Add `recordedAt`, when the meeting happened, if the source knows it, and a `title`. Keep `id` and `statusUrl` from the `202`. 2. **Wait** about a minute between status reads, or read the feed: see [Polling and the events feed](/guides/polling-and-events/). 3. **At `NAMING`,** send `naming.url` to the person, in the same Slack thread for example, and go on waiting. Or skip naming: `POST …/naming` with `{ "action": "skip" }`, or send the meeting with `naming=skip` in the first place. 4. **At `READY`,** send the person `url`: the meeting, and its Lobbe, in the app. 5. **At `FAILED`,** retry if `error.retryable` is `true`, and go on waiting: Lobbe retries a failed step by itself too, so a `FAILED` can be temporary, and your retry never starts a second run (`409 NOT_FAILED` means the meeting has already recovered). After a few retries, or when `error.retryable` is `false`, tell the person. ### In n8n An HTTP Request node sends the meeting: ```text HTTP Request node Method: POST URL: https://app.lobbe.ai/api/v1/recordings Credential (Header Auth): Authorization = Bearer Body: Form-Data, field audio = the meeting's file Then poll the statusUrl it answers with, about once a minute. ``` The file goes in the `audio` field with its type. A `400 VALIDATION` that says `Unsupported audio type` means it went without one: see [Upload limits](/start/limits/#upload-limits). Treat the token like a password: see [Keep it safe](/start/tokens/#keep-it-safe). The [n8n recipe](/tools/n8n/) sets this node up step by step, with the token in a `Header Auth` credential rather than in the node, and adds the loop that waits for the meeting. ### With curl The same flow from a shell. It sends the meeting, prints the naming link once if the meeting waits for names, retries a failure that can recover (up to three times), and stops at `READY`, at a failure it can't retry, or on any error, printing it (it needs `jq` and `uuidgen`): ```bash key=$(uuidgen) # one key for this file, kept for every retry of it body=$(curl -sS --fail-with-body https://app.lobbe.ai/api/v1/recordings \ -H "Authorization: Bearer $LOBBE_TOKEN" \ -H "Idempotency-Key: $key" \ -F "audio=@meeting.m4a;type=audio/mp4" \ -F "title=Weekly product sync" \ -F "recordedAt=2026-10-19T10:30:00+03:30") status_url=$(jq -r '.recording.statusUrl // empty' <<<"$body") [ -n "$status_url" ] || echo "$body" told="" retries=0 while [ -n "$status_url" ]; do body=$(curl -sS --fail-with-body "$status_url" \ -H "Authorization: Bearer $LOBBE_TOKEN") || { echo "$body"; break; } status=$(jq -r .status <<<"$body") if [ "$status" = NAMING ] && [ -z "$told" ]; then echo "Name the speakers: $(jq -r .naming.url <<<"$body")" told=yes fi if [ "$status" = READY ]; then echo "The Lobbe is ready: $(jq -r .url <<<"$body")" break fi if [ "$status" = FAILED ]; then if [ "$(jq -r .error.retryable <<<"$body")" != true ] || [ "$retries" -ge 3 ]; then echo "It failed: $(jq -c .error <<<"$body")" break fi retries=$((retries + 1)) # 409 NOT_FAILED: it recovered by itself in the meantime. Keep waiting either way. body=$(curl -sS --fail-with-body -X POST "${status_url%/status}/retry" \ -H "Authorization: Bearer $LOBBE_TOKEN") || [ "$(jq -r .error.code <<<"$body" 2>/dev/null)" = NOT_FAILED ] || { echo "$body"; break; } fi sleep 60 done ``` With **Name speakers** set to **Ask**, a meeting can wait for its names for hours. Reading its status once a minute all that time stays far inside the token's budget. :::note Sending meetings, creating and editing series, changing settings, search and Ask through a token are being switched on gradually. If the form in Connections doesn't offer a scope, it isn't available to your account yet. ::: --- # Idempotency and retries > Send a request that spends minutes again, after an error or a lost answer, without paying twice. A network can lose the answer to a request that worked. Sending it again must not make a second meeting or pay for a second Ask. An `Idempotency-Key` header makes the second try safe. ## What takes a key The three routes that spend minutes take an `Idempotency-Key` header: | Route | Scope | A replay answers | | --- | --- | --- | | [`POST /recordings`](/reference/recordings/#submitRecording) | `recordings:write` | `202` with the same meeting | | [`POST /recordings/{id}/ask`](/reference/recordings/#askRecording) | `recordings:query` | `200` with the same answer | | [`POST /series/{id}/ask`](/reference/series/#askSeries) | `series:query` | `200` with the same answer | - A key is 8 to 64 characters, counted after spaces at either end are trimmed. Anything else answers `400 VALIDATION`. - It is optional, and worth sending on every request to these routes: without one, Lobbe can't tell a retry from a new request. - On the two Asks, the key can also go in the body as `idempotencyKey`. When both are sent, the header wins. - The other routes spend nothing, so they take no key. ```bash key=$(uuidgen) # one key for this meeting, kept for every retry of it curl https://app.lobbe.ai/api/v1/recordings \ -H "Authorization: Bearer $LOBBE_TOKEN" \ -H "Idempotency-Key: $key" \ -F "audio=@meeting.m4a;type=audio/mp4" ``` ## Replays Send a request again with the same key, and Lobbe doesn't run it again: it answers with what the first one made. - **A meeting:** `202` with the same meeting, its `id` and its status now. There is no second meeting and no second charge, and the token's daily cap counts it once. - **An Ask:** `200` with the first answer, and nothing is charged. - **A replay is answered even when a limit is reached:** Lobbe's daily limits, or the token's daily cap. - **Lobbe matches on the key alone.** The same key with another file, or another question, still gets the first meeting or the first answer back, and the new one is never sent. A new request needs a new key. - **Only a request that worked is replayed.** One that answered an error stored nothing, so sending it again with the same key runs it for real. That is what you want after a `5xx`, or after a `402` once the owner has bought minutes. - **`503 UPLOAD_IN_PROGRESS`** means the first request with this key is still storing its audio. Wait the seconds `Retry-After` gives and send the same request again: it then answers with that meeting. ## A key is the owner's, and doesn't expire - **A key doesn't expire** after 24 hours, or a week. A meeting's key stays with the meeting it made for the life of that meeting, even after it is deleted. An Ask's key stays with the Asks of that meeting or series. - **A meeting's key is the account's.** One key makes one meeting, across the app and every token of the account. An Ask's key belongs to one meeting or one series, so another meeting can use the same key. - **Each token replays only its own requests.** A key that another token of the account, or the app, already used answers `409 IDEMPOTENCY_KEY_IN_USE`. The answer names no meeting and no answer: the earlier one never reaches this token. So send a random UUID for each new request. Never make the key from the file, a hash of the audio for example: two agents of one account that hash the same file would collide, and the second would get `409 IDEMPOTENCY_KEY_IN_USE` instead of its meeting. ## Retrying - **Make the key once,** before the first try, and keep it for every retry of that request. If your tool can restart, store the key with the job. - **Network errors and `5xx`:** send the same request again with the same key, waiting longer each time (2 seconds, then 4, 8 and so on, up to a limit you choose). If the first try had in fact worked, the retry gets its outcome. - **`429`:** wait the seconds `Retry-After` gives, then send the same request with the same key. For a daily limit, that can be hours. A `429` without `Retry-After` won't pass by waiting: that is a `TOKEN_DAILY_CAP` this request alone is bigger than, or Lobbe's own `DAILY_LIMIT_REACHED` on a meeting. Stop, and tell the person. - **An Ask that fails** (`502 GENERATE_FAILED`) isn't charged, but it can count toward Lobbe's daily limit on Asks: wait before trying again, and don't loop on it. - **Other `4xx`:** the request as it is won't work. Change it as its code says: see [Errors](/guides/errors/). `409 IDEMPOTENCY_KEY_IN_USE` needs a new key. A submit that does all of this (it needs `uuidgen`; any random UUID will do): ```bash key=$(uuidgen) delay=2 for attempt in 1 2 3 4 5 6; do rm -f headers.txt body.json code=$(curl -sS -o body.json -D headers.txt -w '%{http_code}' \ https://app.lobbe.ai/api/v1/recordings \ -H "Authorization: Bearer $LOBBE_TOKEN" \ -H "Idempotency-Key: $key" \ -F "audio=@meeting.m4a;type=audio/mp4") || code=000 wait=$(awk -F': *' 'tolower($1) == "retry-after" { print $2 + 0 }' headers.txt 2>/dev/null) case "$code" in 202) cat body.json; break ;; 429) [ -n "$wait" ] || { cat body.json; break; }; sleep "$wait" ;; 5?? | 000) sleep "${wait:-$delay}"; delay=$((delay * 2)) ;; *) cat body.json; break ;; esac [ "$attempt" = 6 ] && { echo "gave up after 6 tries"; cat body.json 2>/dev/null; } done ``` `000` is a network error: curl got no answer. The loop sends the same key every time, so however many tries it takes, there is one meeting. :::note Sending meetings, creating and editing series, changing settings, search and Ask through a token are being switched on gradually. If the form in Connections doesn't offer a scope, it isn't available to your account yet. ::: --- # Errors > The shape of an error, every code the API answers with, and the codes that are easy to confuse. ## The shape of an error An error answers with a JSON body like this one: ```json { "error": { "code": "TOKEN_DAILY_CAP", "message": "This token's daily minute cap is reached", "details": { "capMinutes": 60, "usedMinutes": 55, "requiredMinutes": 12 } } } ``` - **`code` is the contract.** Build your logic on it. - **`message` is for people.** Log it or show it, but never match on it: it may change. The `500` for an unexpected failure, `INTERNAL_ERROR`, carries `code` alone. - **`details`** comes with a few codes only, and holds numbers you can act on: | Code | `details` | | --- | --- | | `402 INSUFFICIENT_MINUTES` | `requiredMinutes`, and `balanceMinutes` for a token holding `account:read` | | `429 TOKEN_DAILY_CAP` | `capMinutes`, `usedMinutes`, `requiredMinutes` | | `409 PRICE_ABOVE_MAX` | `minutes`, `maxMinutes` | | `409 NOT_READY` (the bundle) | `status`, beside `error` rather than inside it | - **The HTTP status alone isn't enough.** Several codes share one status: many different refusals are a `409`. - **A path under `/api/` that isn't an API route** answers a plain-text `404 Not Found`, not this JSON. Check the path against the [reference](/reference/). ## Two exceptions, as they are ### The bundle's `409 NOT_READY` [`GET /recordings/{id}/bundle`](/reference/recordings/#getRecordingBundle) on a meeting that isn't `READY` puts `details` beside `error`, not inside it: ```json { "error": { "code": "NOT_READY", "message": "The meeting is not ready yet" }, "details": { "status": "TRANSCRIBING" } } ``` This answer is older than the rule, and moving `details` would break the scripts that read it, so it stays: read `details.status` at the top level here. It says where the meeting is: wait for `READY`, unless it is `FAILED`, in which case see [`FAILED` and retry](/guides/lifecycle/#failed-and-retry). ### `402 INSUFFICIENT_MINUTES` `details.requiredMinutes` is always there. `details.balanceMinutes` comes only to a token holding `account:read`: a token without it is never told the balance. What that balance counts is in [Minutes, caps and limits](/start/limits/#running-short). ## Codes that look alike ### `TOKEN_SCOPE` and `SCOPE_DISABLED` Both are `403`. - **`TOKEN_SCOPE`:** the token holds none of the scopes this route needs, and never will, since a token's scopes are fixed when it is made. Don't retry: the owner makes a token with that scope. - **`SCOPE_DISABLED`:** the token holds the scope, but Lobbe has switched that kind of access off for now. The token is fine: try again later. A token without the scope always gets `TOKEN_SCOPE`, switch or no switch. So `SCOPE_DISABLED` always means the token has what it needs and only has to wait. :::note Sending meetings, creating and editing series, changing settings, search and Ask through a token are being switched on gradually. If the form in Connections doesn't offer a scope, it isn't available to your account yet. ::: ### `TOKEN_INVALID` and `DEVELOPER_MODE_OFF` - **`TOKEN_INVALID` (`401`):** the token itself is no good. It is unknown, revoked or expired, one code for all three, and the answer never says which. Don't retry: the owner makes a new token in **Connections**. - **`DEVELOPER_MODE_OFF` (`403`):** the token is fine, but its owner has turned **Developer mode** off, which pauses every token of the account. Nothing is revoked: once it is back on, the same token works again. Don't treat it as a bad token, and don't retry it in a loop. Tell the person to turn **Developer mode** back on: in the app, **Account**, under **Developers**. A revoked or expired token answers `TOKEN_INVALID` either way: the token is checked before the mode. ## Every code The table lists every code, its HTTP status, what it means and what to do next. It starts with the checks every token request goes through, in the order Lobbe makes them. The last column says what your tool does next: - **Don't repeat it:** the same request won't work later on its own. Something has to change first, often on the person's side. - **Wait for `Retry-After`:** wait the seconds it gives, then send the same request. - **Change the request:** the request itself is wrong, and `message` usually says how. - **Try again later:** the request is fine. Something on Lobbe's side, or a switch, is in the way for now. When you send a request again, send it with the same `Idempotency-Key`: see [Idempotency and retries](/guides/idempotency/). | Code | HTTP status | What it means | Next step | | --- | --- | --- | --- | | `UNAUTHORIZED` | `401` | No token came with the request (a missing `Authorization` header, or one that isn't `Bearer lobbe_…`, reads as a signed-out caller): send `Authorization: Bearer lobbe_…`. | Change the request | | `TOKEN_NOT_ACCEPTED` | `401` | This route doesn't take API tokens: call only the routes in the API reference. | Change the request | | `TOKENS_DISABLED` | `401` | Lobbe has switched API tokens off for everyone for now, and your token is kept: try again later. | Try again later | | `TOKEN_INVALID` | `401` | The token is unknown, revoked or expired, and the answer never says which: don't retry it; the owner makes a new token in Connections. | Don't repeat it | | `DEVELOPER_MODE_OFF` | `403` | The token is fine, but its owner has turned Developer mode off in the app's settings (Developers), which pauses every token of the account until they turn it back on: don't treat it as a bad or revoked token and don't retry in a loop; tell the person to turn Developer mode on. | Don't repeat it | | `RATE_LIMITED` | `429` | Too many requests came in a short time, from this token or from your IP address: wait the seconds `Retry-After` gives, then send again. | Wait for `Retry-After` | | `TOKEN_SCOPE` | `403` | This token doesn't hold the scope this route needs: don't retry it; the owner makes a token that has it. | Don't repeat it | | `SCOPE_DISABLED` | `403` | The token holds the scope, but Lobbe has switched that kind of access off for now: try again later. | Try again later | | `PENDING_APPROVAL` | `403` | The owner's account is still waiting for approval, so it can't be used yet: try again once it is approved. | Try again later | | `SCOPE_UNAVAILABLE` | `400` | The app won't make a token with a scope that is switched off for now: pick from the scopes the create screen offers, and make another token once the rest are on. | Try again later | | `TOKEN_REQUIRED` | `400` | `whoami` describes an API token, and this request came from a signed-in browser instead: call it with `Authorization: Bearer lobbe_…`. | Change the request | | `VALIDATION` | `400` | The request is malformed (a field, parameter or header is missing, unknown or out of range), and `message` says which: fix it before sending again. | Change the request | | `NOT_FOUND` | `404` | Nothing this token can reach has that id (unknown, deleted, someone else's or out of the token's reach all look the same): don't retry the same id. | Don't repeat it | | `INTERNAL` | `500` | Lobbe couldn't finish a step on its side (storing the audio, or saving an answer): try again later, and with the same `Idempotency-Key` a retry is never charged twice. | Try again later | | `INTERNAL_ERROR` | `500` | Something unexpected failed on Lobbe's side: try again later, and with the same `Idempotency-Key` a retry is never charged twice. | Try again later | | `INVALID_AUDIO` | `400` | The file isn't audio Lobbe can read: send it in another format, or from another export. | Change the request | | `AUDIO_INCOMPLETE` | `400` | The audio file is cut short (an MP4-family file whose end is missing, usually a copy or upload that stopped early): send the complete file. | Change the request | | `UPLOAD_TOO_LARGE` | `413` | The file is larger than the upload limit, which `whoami` gives as `limits.maxUploadMb`: send a smaller file. | Change the request | | `RECORDING_TOO_LONG` | `413` | The audio is longer than one meeting may be, which `whoami` gives as `limits.maxRecordingMinutes`: split it and send the parts. | Change the request | | `VIDEO_UPLOAD_DISABLED` | `409` | Video upload is switched off: send the meeting's audio instead. | Change the request | | `VIDEO_UNAVAILABLE` | `503` | Lobbe can't take the audio out of a video right now: try again later, or send the audio itself. | Try again later | | `VIDEO_NO_AUDIO` | `400` | The video has no audio track, so there is nothing to transcribe: send a file with sound. | Change the request | | `VIDEO_UNREADABLE` | `400` | The video can't be read: send it in another format, or send its audio. | Change the request | | `ATTACHMENT_TYPE` | `400` | The attachment isn't a PDF, PPTX or DOCX file: send one of those, or no attachment. | Change the request | | `ATTACHMENT_TOO_LARGE` | `413` | The attachment is larger than 10 MB: send a smaller file. | Change the request | | `DAILY_LIMIT_REACHED` | `429` | The owner has reached Lobbe's own daily limit (on meetings, or on Asks over the last 24 hours), which no token raises: try again later, after `Retry-After` when it is sent. | Try again later | | `INSUFFICIENT_MINUTES` | `402` | The owner's balance is short of what this costs: `details.requiredMinutes` says how many minutes it needs, plus `details.balanceMinutes` only for a token holding `account:read`; don't retry until the owner buys minutes. | Don't repeat it | | `TOKEN_DAILY_CAP` | `429` | This would take the token past its daily minute cap (`details` gives `capMinutes`, `usedMinutes` and `requiredMinutes`): wait for `Retry-After`; when it is absent, waiting can't help and the owner raises the cap in Connections. | Wait for `Retry-After` | | `IDEMPOTENCY_KEY_IN_USE` | `409` | Another token of this account, or the app, already used this `Idempotency-Key`, and a key is the owner's for good (across the account for a meeting, on that meeting or series for an Ask): send a new random key, never a hash of the file. | Change the request | | `UPLOAD_IN_PROGRESS` | `503` | An earlier request with this `Idempotency-Key` is still storing its audio: wait for `Retry-After` and send the same request again, which then answers with that meeting. | Wait for `Retry-After` | | `NOT_NAMING` | `409` | The meeting isn't waiting at the speaker-naming step (it is already past it, or hasn't reached it): read its status instead of skipping again. | Don't repeat it | | `NOT_FAILED` | `409` | Only a failed meeting can be retried, and this one hasn't failed: read its status instead. | Don't repeat it | | `NOT_READY` | `409` | The meeting isn't ready yet; on the bundle, `details` sits beside `error`, not inside it, and `details.status` says where the meeting is: poll its status and read the bundle once it is `READY`. | Try again later | | `ASK_DISABLED` | `409` | Ask is switched off in Lobbe for now: try again later. | Try again later | | `ASK_NOT_READY` | `409` | The meeting has no Lobbe yet, so there is nothing to ask: wait until its status is `READY`. | Try again later | | `PRICE_ABOVE_MAX` | `409` | The answer would cost more than your `maxMinutes` (`details` gives `minutes` and `maxMinutes`), and nothing was charged: raise `maxMinutes`, or don't ask. | Change the request | | `SERIES_ASK_TOO_FEW` | `409` | A series needs at least 2 finished meetings to ask across: try again once it has them. | Try again later | | `GENERATE_FAILED` | `502` | The model couldn't answer this time, and nothing was charged: try again later with the same `Idempotency-Key`. | Try again later | | `SERIES_LIMIT` | `409` | The owner already has 50 series, the most one account may have: don't retry; the owner deletes one in the app first. | Don't repeat it | | `SERIES_NAME_TAKEN` | `409` | The owner already has a series with this name (capitals don't make a name different): choose another name. | Change the request | | `MEMORY_DISABLED` | `409` | Memory is switched off in Lobbe, so a note can't be edited or deleted (reading answers an empty list): try again later. | Try again later | | `MEMORY_DUPLICATE` | `409` | The owner already has this note in the same category: edit or delete that one instead. | Change the request | | `CURSOR_EXPIRED` | `410` | The cursor is older than the 30 days the feed keeps, or comes from before a restore of Lobbe's data: take a new cursor with `after=latest` first, then catch up from `listReadyRecordings`, then read on from that new cursor. | Change the request | --- # Connect an AI agent > Point an AI agent at Lobbe's OpenAPI document with a token of its own, and give it the rules to follow. An agent that can send HTTP requests, Claude Code or Codex for example, can work with Lobbe through the API. It learns the routes from Lobbe's OpenAPI document and sends each request with a token you made for it. ## Before you start - **Make the agent a token of its own,** with only the scopes its work needs and, if it spends minutes, a [daily cap](/start/tokens/#the-daily-cap). See [Tokens and scopes](/start/tokens/). - **Keep Developer mode on.** While it is off, every request the agent sends answers `403 DEVELOPER_MODE_OFF`: see [Developer mode](/start/tokens/#developer-mode). ## Set it up ```bash export LOBBE_TOKEN=lobbe_… # Then tell your agent: # Use the Lobbe API at https://app.lobbe.ai/api/v1 with the bearer token in $LOBBE_TOKEN. # Its OpenAPI document: https://app.lobbe.ai/api/v1/openapi.json # Ask for a meeting's status no more than once a minute. ``` Put the token in `LOBBE_TOKEN`, in the environment the agent runs in. Then give the agent the lines after `# Then tell your agent:`, in its instructions or at the start of a conversation. They name the variable, never the token itself. In the app, **Connections** › **Agents** shows the same lines. ## What the agent can read - **The OpenAPI document,** `https://app.lobbe.ai/api/v1/openapi.json`: every route, its fields and its errors. Reading it needs no token. Each operation also says which scopes it needs (`x-lobbe-scopes`), whether it spends minutes (`x-lobbe-spends`) and its rate limit (`x-lobbe-rate-limit`). These docs serve the same document at [`/openapi.json`](https://docs.lobbe.ai/openapi.json). - **These docs, as Markdown.** [`llms.txt`](https://docs.lobbe.ai/llms.txt) lists every page with a link to its Markdown, and [`llms-full.txt`](https://docs.lobbe.ai/llms-full.txt) holds every page in one file; those two files are in English. Each page, in either language, is also Markdown at its own address, with `.md` in place of the trailing slash: `https://docs.lobbe.ai/guides/lifecycle.md` or `https://docs.lobbe.ai/fa/guides/lifecycle.md`. The two home pages are the exception: `https://docs.lobbe.ai/index.md` and `https://docs.lobbe.ai/fa/index.md`. The lines above name the OpenAPI document only. For the agent to read the guides too, add `https://docs.lobbe.ai/llms.txt` to what you tell it. ## Rules for the agent Tell the agent these as well: - **Ask for a meeting's status no more than once a minute.** Most meetings are done within a few minutes. To follow many meetings, read the [events feed](/guides/polling-and-events/#the-events-feed) instead. On a `429`, wait the seconds `Retry-After` gives. - **At `NAMING`, give the person `naming.url`,** and don't guess the speakers' names. The link opens the meeting's speaker naming in the app, for its owner, and the Lobbe's decisions and action items then carry real names. Skip naming only when the person says to: see [Speaker naming](/guides/lifecycle/#speaker-naming). - **Get the price before an Ask.** The quote is free. Send the Ask with `maxMinutes` set to the quote's `minutes`, so it never costs more than that. Every answer spends minutes, a refusal included: see [Ask a meeting](/guides/search-and-ask/#ask-a-meeting). - **Send an `Idempotency-Key` with each request that spends,** made once and kept for every retry of that request, so a retry never pays twice: see [Idempotency and retries](/guides/idempotency/). - **Never print the token.** Read it from `$LOBBE_TOKEN`. Never echo it, put it in a URL, write it to a file or a log, or quote it in an answer: see [Keep it safe](/start/tokens/#keep-it-safe). - **On `403 DEVELOPER_MODE_OFF`, stop and tell the person.** The owner has turned Developer mode off, which pauses every token of the account, and retrying can't help until it is back on: see [Errors](/guides/errors/#token_invalid-and-developer_mode_off). ## What's coming A Lobbe MCP server and a CLI are coming. Until then, an agent connects through the OpenAPI document. :::note Sending meetings, creating and editing series, changing settings, search and Ask through a token are being switched on gradually. If the form in Connections doesn't offer a scope, it isn't available to your account yet. ::: --- # n8n recipe > An n8n workflow that sends a meeting to Lobbe, waits until it is ready, and reads its Lobbe as Markdown. This workflow takes a meeting's file from the node before it, sends it to Lobbe, waits for it, and reads the finished meeting as one Markdown document, ready for a note, a message or an email. It has four HTTP Request nodes: one sends, one asks for the status in a loop with a Wait node and three IF nodes, one retries a failure that can recover, and one reads. A workflow that only hands meetings over, and never reads them, needs less: see [A submit-only flow](/guides/submit-only/). ## Before you start **The token.** Make one in **Connections** with **Send meetings** and **Read** (`recordings:write` and `recordings:read`). **Read** is what lets the last node fetch the meeting. The token spends minutes, so give it a [daily cap](/start/tokens/#the-daily-cap). Like every token, it works only while [Developer mode](/start/tokens/#developer-mode) is on. **The credential.** Keep the token in an n8n credential, never in a node: 1. In n8n, create a credential of the type `Header Auth`. 2. Set its `Name` to `Authorization`, and its `Value` to `Bearer lobbe_…`: the word `Bearer`, a space, then your token. 3. In each node that calls Lobbe, set `Authentication` to `Generic Credential Type` and `Generic Auth Type` to `Header Auth`, then pick this credential. A node's settings are saved with the workflow, and workflows get exported and shared. An exported workflow names its credentials, but doesn't carry them. ## 1. Send the meeting ```text HTTP Request node Method: POST URL: https://app.lobbe.ai/api/v1/recordings Credential (Header Auth): Authorization = Bearer Body: Form-Data, field audio = the meeting's file Then poll the statusUrl it answers with, about once a minute. ``` The `Credential (Header Auth)` line above is what the credential sends: don't type it into the node. The node's settings: | Setting | Value | | --- | --- | | `Method` | `POST` | | `URL` | `https://app.lobbe.ai/api/v1/recordings` | | `Authentication` | The `Header Auth` credential | | `Send Body` | On, with `Body Content Type` set to `Form-Data` | | A body parameter | `Parameter Type` `n8n Binary File`, `Name` `audio`, and as `Input Data Field Name` the item's field that holds the file (`data`, unless an earlier node named it otherwise) | | A second body parameter | `Parameter Type` `Form Data`, `Name` `naming`, `Value` `skip` | - **`audio`** is the field Lobbe reads the file from. n8n sends the file with the type in its binary data's `mimeType`. A `400 VALIDATION` with the message `Unsupported audio type` means that type isn't one Lobbe takes: see [Upload limits](/start/limits/#upload-limits). - **`naming=skip`** means the meeting never stops for its speakers' names, so the loop below always ends. Without it, the owner's **Name speakers** setting decides, and a meeting can wait at `NAMING` for hours: see [Skipping naming at upload](/guides/lifecycle/#skipping-naming-at-upload). - **More fields,** such as `title` and `recordedAt` (when the meeting happened), go in as more `Form Data` parameters: see [`POST /recordings`](/reference/recordings/#submitRecording). - **Retries.** If you turn on the node's `Retry On Fail`, also send an `Idempotency-Key` header, with a value made once for each file before this node, so a retry can't make a second meeting: see [Idempotency and retries](/guides/idempotency/). Name this node `Send meeting`: the loop below reads the meeting's id from it. The answer is `202`, with the id in `recording.id`. ## 2. Wait for it Six nodes make a loop that asks for the status once a minute and retries a failure that can recover: a Wait node, two HTTP Request nodes and three IF nodes. A Stop and Error node ends the run on a failure that can't. 1. **A Wait node:** `Resume` set to `After Time Interval`, for 1 minute. 2. **An HTTP Request node** for the status: `Method` `GET`, the same credential, and as `URL` this expression: ```text https://app.lobbe.ai/api/v1/recordings/{{ $('Send meeting').item.json.recording.id }}/status ``` It is the `statusUrl` the send answered with. The id comes from the `Send meeting` node every time round, whatever the node before this one answered. If you named that node otherwise, put its name in the expression. 3. **An IF node** with the condition `{{ $json.status }}` is equal to `READY`. Its `true` output goes on to step 3. Its `false` output goes to a second IF node. 4. **A second IF node** with the condition `{{ $json.status }}` is equal to `FAILED`. Its `false` output goes back to the Wait node, and its `true` output to a third IF node. 5. **A third IF node** with two conditions, both required (`AND`): `{{ $json.error.retryable }}` is true (`Boolean`), and `{{ $runIndex }}` is less than `3` (`Number`). `$runIndex` counts this node's earlier runs, so a meeting is retried at most three times. Its `true` output goes to the retry node below. Its `false` output ends the run, through a Stop and Error node with `{{ $json.error.code }}` in its message. 6. **An HTTP Request node** for the retry: `Method` `POST`, the same credential, `Never Error` on (under `Options`, then `Response`), and as `URL` this expression: ```text https://app.lobbe.ai/api/v1/recordings/{{ $('Send meeting').item.json.recording.id }}/retry ``` Its output goes back to the Wait node. Why retry instead of stopping: Lobbe retries a failed step by itself after a short wait, so the status can read `FAILED` for a moment and then move on. The retry is safe even then. It never starts a second run while Lobbe's own is pending, it costs nothing more, and it answers `409 NOT_FAILED` when the meeting has already recovered: `Never Error` keeps that answer from ending the run. Any other answer to the retry is left to the next status read: the meeting either moves on, or reads `FAILED` again and uses up one more retry. A meeting whose `error.retryable` is `false` (no speech in it, or a damaged file) would fail the same way again, so the loop stops at once. See [`FAILED` and retry](/guides/lifecycle/#failed-and-retry). ## 3. Read the Lobbe At `READY`, the status carries `bundleUrl`, the address of the whole meeting. One more HTTP Request node reads it: | Setting | Value | | --- | --- | | `Method` | `GET` | | `URL` | `{{ $json.bundleUrl }}?format=markdown` | | `Authentication` | The same credential | | `Response Format` (under `Options`, then `Response`) | `Text` | The answer is one Markdown document: the title, the meeting's details, the Lobbe's sections and the transcript. n8n puts it in the item's `data` field, for the next node to use. Without `?format=markdown`, the same meeting comes as JSON: see [`GET /recordings/{id}/bundle`](/reference/recordings/#getRecordingBundle). `bundleUrl` is `null` for a token without **Read**, so this node needs `recordings:read`. ## When a request fails Apart from the retry node, leave each HTTP Request node to stop the workflow on an error, as n8n does by default. Then an error answer, a `403 DEVELOPER_MODE_OFF` or a `402 INSUFFICIENT_MINUTES` for example, ends the run instead of going round the loop. To hear about it, pick an `Error Workflow` in the workflow's settings. What each code means is in [Errors](/guides/errors/). ## Read finished meetings instead A token with **Read** alone can't send meetings, but one node lists the finished ones: ```text HTTP Request node Method: GET URL: https://app.lobbe.ai/api/v1/recordings/ready Credential (Header Auth): Authorization = Bearer Each item's bundleUrl returns the whole meeting. ``` The answer's `items` are your finished meetings, newest first. For the next page, send `nextCursor` back as `cursor`. To fetch one meeting as Markdown, add `?format=markdown` to its `bundleUrl`, as in step 3. To pick up each new meeting once it is ready, see [Pull meetings into your own tools](/guides/pulling-meetings/). :::note Sending meetings, creating and editing series, changing settings, search and Ask through a token are being switched on gradually. If the form in Connections doesn't offer a scope, it isn't available to your account yet. ::: --- # API reference > Every route of the Lobbe API, its base URL, its OpenAPI document and its conventions Every route a personal API token can call, grouped as in the sidebar. Each entry gives the route's scopes, whether it spends minutes, its rate limit, and its parameters and fields. - **Base URL:** `https://app.lobbe.ai/api/v1` - **Authentication:** every request sends `Authorization: Bearer $LOBBE_TOKEN`. - **OpenAPI document:** `https://app.lobbe.ai/api/v1/openapi.json`, also served here as [`/openapi.json`](/openapi.json). These pages are generated from the same document. :::note Sending meetings, creating and editing series, changing settings, search and Ask through a token are being switched on gradually. If the form in Connections doesn't offer a scope, it isn't available to your account yet. ::: ## Token | Route | What it does | Scopes | | --- | --- | --- | | [`GET /whoami`](/reference/token/#whoami) | Who this token belongs to, its scopes, its daily cap and the upload limits | Any live token | ## Meetings | Route | What it does | Scopes | | --- | --- | --- | | [`GET /recordings/ready`](/reference/recordings/#listReadyRecordings) | List finished meetings, newest first | `recordings:read` | | [`GET /recordings/search`](/reference/recordings/#searchRecordings) | Search the owner's finished meetings by their words, newest meeting first | `recordings:query` | | [`GET /recordings/{id}/ask/quote`](/reference/recordings/#quoteRecordingAsk) | What asking this meeting a question will cost, in minutes | `recordings:query` | | [`POST /recordings/{id}/ask`](/reference/recordings/#askRecording) | Ask one meeting a question, answered from its transcript and Lobbe; each answer is charged | `recordings:query` | | [`GET /recordings/{id}/bundle`](/reference/recordings/#getRecordingBundle) | One finished meeting: its transcript, its Lobbe and its stats; ?format=markdown returns one Markdown document | `recordings:read` | | [`GET /recordings/{id}/status`](/reference/recordings/#getRecordingStatus) | One meeting's progress; at NAMING, the link that asks the owner to name the speakers | `recordings:read` or `recordings:write` | | [`POST /recordings`](/reference/recordings/#submitRecording) | Submit a meeting's audio (multipart); answers 202 with its status link | `recordings:write` | | [`POST /recordings/{id}/naming`](/reference/recordings/#skipRecordingNaming) | Skip naming the speakers: unnamed voices take Lobbe's suggestions and the Lobbe is made | `recordings:write` | | [`POST /recordings/{id}/retry`](/reference/recordings/#retryRecording) | Retry a failed meeting: the step that failed runs again, at no new charge | `recordings:write` | ## Events | Route | What it does | Scopes | | --- | --- | --- | | [`GET /events`](/reference/events/#listEvents) | What happened to the owner's meetings since a cursor: created, waiting for names, ready, failed, updated, deleted | `recordings:read` or `recordings:write` | ## Series | Route | What it does | Scopes | | --- | --- | --- | | [`GET /series/readable`](/reference/series/#listReadableSeries) | The series you own or joined | `series:read` | | [`GET /series/{id}`](/reference/series/#getSeries) | One series you own or joined: its name, its defaults and how many finished meetings it holds | `series:read` | | [`PATCH /series/{id}`](/reference/series/#updateSeries) | Change a series you own: its name, type and default language, title style or calendar | `series:write` | | [`POST /series`](/reference/series/#createSeries) | Create a series: its name, type and default language, title style and calendar | `series:write` | | [`GET /series/{id}/keyterms`](/reference/series/#getSeriesKeyterms) | A series' vocabulary (owner only), and whether the account's vocabulary applies too | `series:read` | | [`PUT /series/{id}/keyterms`](/reference/series/#replaceSeriesKeyterms) | Replace a series' vocabulary (owner only); a meeting that uses vocabulary carries its surcharge | `series:write` | | [`GET /series/{id}/ask/quote`](/reference/series/#quoteSeriesAsk) | What asking this series a question will cost, in minutes, and over how many meetings | `series:query` | | [`POST /series/{id}/ask`](/reference/series/#askSeries) | Ask a whole series a question, answered across its finished meetings; each answer is charged | `series:query` | ## Account | Route | What it does | Scopes | | --- | --- | --- | | [`GET /account`](/reference/account/#getAccount) | The account: who owns it, its wallet balance in minutes, and its settings | `account:read` | | [`GET /account/keyterms`](/reference/account/#getAccountKeyterms) | The account's vocabulary: words transcription should recognize | `account:read` | | [`PUT /account/keyterms`](/reference/account/#replaceAccountKeyterms) | Replace the account's vocabulary; a meeting that uses vocabulary carries its surcharge | `account:write` | | [`GET /account/memory`](/reference/account/#listMemory) | What Lobbe remembers about the account's owner, as the settings screen lists it | `account:read` | | [`PATCH /account/settings`](/reference/account/#updateAccountSettings) | Change speaker naming, memory learning or the summary email; nothing else | `account:write` | | [`PATCH /account/memory/{id}`](/reference/account/#updateMemoryItem) | Rewrite one memory item; an edited item is kept as you wrote it | `account:write` | | [`DELETE /account/memory/{id}`](/reference/account/#deleteMemoryItem) | Delete one memory item | `account:write` | ## Conventions - Every successful JSON response carries `schemaVersion: 1`. Ignore fields you don't know. An error body is `{ error: … }` only, with no `schemaVersion`, and `?format=markdown` answers Markdown text. - `x-lobbe-scopes` lists the scopes that open a route: any one of them admits a token. `"any"` (on [`GET /whoami`](/reference/token/#whoami)) means every live token, whatever its scopes. - `x-lobbe-spends` is `true` on a route that can spend minutes; send those an `Idempotency-Key` header ([Idempotency and retries](/guides/idempotency/)). - `x-lobbe-rate-limit` names the route's `class` and that class's budget per token, `requests` per `windowSeconds`. Every route in a class shares the one budget (the table below). - `x-lobbe-summary-fa` and `x-lobbe-description-fa` carry the same texts in Persian. - `PATCH /series/{id}` and `PATCH /account/settings` take at least one field (`minProperties: 1`): an empty body is a `400`. - Errors answer `{ error: { code, message, details? } }`: `code` is the contract, and `message` may change. Every code is on [Errors](/guides/errors/). - One exception keeps its original shape: the bundle's `409 NOT_READY` puts `details` beside `error`, not inside it, and `details.status` says where the meeting is: ```json { "error": { "code": "NOT_READY", "message": "…" }, "details": { "status": "TRANSCRIBING" } } ``` ## Rate limits | Class | Budget per token | Routes | | --- | --- | --- | | `read` | 600 requests per 10 minutes | [`GET /whoami`](/reference/token/#whoami), [`GET /recordings/ready`](/reference/recordings/#listReadyRecordings), [`GET /recordings/{id}/bundle`](/reference/recordings/#getRecordingBundle), [`GET /recordings/{id}/status`](/reference/recordings/#getRecordingStatus), [`GET /events`](/reference/events/#listEvents), [`GET /series/readable`](/reference/series/#listReadableSeries), [`GET /series/{id}`](/reference/series/#getSeries), [`GET /series/{id}/keyterms`](/reference/series/#getSeriesKeyterms), [`GET /account`](/reference/account/#getAccount), [`GET /account/keyterms`](/reference/account/#getAccountKeyterms), [`GET /account/memory`](/reference/account/#listMemory) | | `submit` | 30 requests per 10 minutes | [`POST /recordings`](/reference/recordings/#submitRecording), [`POST /recordings/{id}/naming`](/reference/recordings/#skipRecordingNaming), [`POST /recordings/{id}/retry`](/reference/recordings/#retryRecording) | | `search` | 120 requests per 10 minutes | [`GET /recordings/search`](/reference/recordings/#searchRecordings), [`GET /recordings/{id}/ask/quote`](/reference/recordings/#quoteRecordingAsk), [`GET /series/{id}/ask/quote`](/reference/series/#quoteSeriesAsk) | | `ask` | 30 requests per 10 minutes | [`POST /recordings/{id}/ask`](/reference/recordings/#askRecording), [`POST /series/{id}/ask`](/reference/series/#askSeries) | | `settings` | 60 requests per 10 minutes | [`POST /series`](/reference/series/#createSeries), [`PATCH /series/{id}`](/reference/series/#updateSeries), [`PUT /series/{id}/keyterms`](/reference/series/#replaceSeriesKeyterms), [`PATCH /account/settings`](/reference/account/#updateAccountSettings), [`PUT /account/keyterms`](/reference/account/#replaceAccountKeyterms), [`PATCH /account/memory/{id}`](/reference/account/#updateMemoryItem), [`DELETE /account/memory/{id}`](/reference/account/#deleteMemoryItem) | --- # Token > The token routes of the Lobbe API Every path is relative to `https://app.lobbe.ai/api/v1`, and every request sends `Authorization: Bearer $LOBBE_TOKEN`. The [API reference](/reference/) lists every route and explains the `x-lobbe-*` fields. ### `GET /whoami` Who this token belongs to, its scopes, its daily cap and the upload limits. | Property | Value | | --- | --- | | Scopes | Any live token | | Spends minutes | No | | Rate limit | `read`: 600 requests per 10 minutes per token | | Success | `200` | #### Response `200` Who this token acts for and what it may do. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `owner` | `object` | Yes | Who the token acts for: the account's owner | | `owner.type` | `"user"` | Yes | The owner's kind: `user` today; ignore a type you don't know | | `owner.id` | `string` | Yes | The owner's account id | | `owner.displayName` | `string \| null` | Yes | The owner's name; null when none was set (a phone number is never sent) | | `token` | `object` | Yes | The token making this request | | `token.id` | `string` | Yes | The token's id | | `token.name` | `string` | Yes | The name the owner gave the token | | `token.scopes[]` | `("recordings:read" \| "recordings:write" \| "recordings:query" \| "series:read" \| "series:write" \| "series:query" \| "account:read" \| "account:write")[]` | Yes | The scopes the token holds, in catalog order; each is `recordings:read`, `recordings:write`, `recordings:query`, `series:read`, `series:write`, `series:query`, `account:read` or `account:write` | | `token.dailyMinuteCap` | `integer \| null` | Yes | The most minutes the token may spend in any rolling 24 hours; null = no cap | | `token.usedMinutes24h` | `integer` | Yes | Minutes the token committed in the last 24 hours, the sum its cap is checked against | | `token.expiresAt` | `string \| null` | Yes | When the token stops working (ISO 8601, UTC); null = it never expires | | `wallet` | `object \| null` | Yes | The account's balance; null unless the token holds `account:read` | | `wallet.balanceMinutes` | `integer` | Yes | The balance in minutes; a meeting not yet transcribed is not taken out of it yet, and a first meeting over the balance can leave it below 0 | | `limits` | `object` | Yes | What one submission may be | | `limits.maxUploadMb` | `number` | Yes | The largest audio file accepted, in megabytes | | `limits.maxRecordingMinutes` | `number` | Yes | The longest meeting accepted, in minutes of audio | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). --- # Meetings > The meetings routes of the Lobbe API Every path is relative to `https://app.lobbe.ai/api/v1`, and every request sends `Authorization: Bearer $LOBBE_TOKEN`. The [API reference](/reference/) lists every route and explains the `x-lobbe-*` fields. :::note Sending meetings, creating and editing series, changing settings, search and Ask through a token are being switched on gradually. If the form in Connections doesn't offer a scope, it isn't available to your account yet. ::: ### `GET /recordings/ready` List finished meetings, newest first. | Property | Value | | --- | --- | | Scopes | `recordings:read` | | Spends minutes | No | | Rate limit | `read`: 600 requests per 10 minutes per token | | Success | `200` | #### Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `limit` | `query` | `integer` | No | How many meetings to return, 1–100 (default 20) | | `cursor` | `query` | `string` | No | The `nextCursor` of the previous page, passed back as is; absent = the newest page, and any other value is a `400` | | `seriesId` | `query` | `string` | No | Only the meetings of this series, one you own or joined (a series you can't read is a `404`); absent = your own meetings | #### Response `200` A page of finished meetings, newest first. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `items[]` | `object[]` | Yes | The meetings on this page: `READY` ones only, the meetings a bundle can be read for | | `items[].id` | `string` | Yes | The meeting's id | | `items[].title` | `string \| null` | Yes | The meeting's title: the one its owner or the submitting token gave it, else its Lobbe's; null when it has none | | `items[].kind` | `"MEETING" \| "LECTURE" \| "INTERVIEW" \| "VOICE_MEMO" \| "CALL"` | Yes | The kind of recording, which picks the Lobbe's template: `MEETING`, `LECTURE`, `INTERVIEW`, `VOICE_MEMO` or `CALL` | | `items[].createdAt` | `string` | Yes | When Lobbe received the meeting (ISO 8601, UTC); `listReadyRecordings` is ordered by it | | `items[].recordedAt` | `string` | Yes | When the meeting happened (ISO 8601, UTC): the `recordedAt` it was submitted with, else when Lobbe received it | | `items[].durationSeconds` | `integer \| null` | Yes | The audio's length in seconds, as Lobbe measured it; null if unknown | | `items[].url` | `string (uri)` | Yes | The meeting's page in the web app | | `items[].bundleUrl` | `string (uri)` | Yes | GET it with a token holding `recordings:read` for the whole meeting (`getRecordingBundle`) | | `items[].series` | `object \| null` | Yes | The series the meeting is filed in; null when it is in none, or while series are switched off | | `items[].series.id` | `string` | Yes | The series' id, as `seriesId` takes it | | `items[].series.name` | `string` | Yes | The series' name | | `items[].displayTitle` | `string \| null` | Yes | The title as the app shows it: in a series titled by date, the series' name and the meeting's date in the Lobbe's language; otherwise the meeting's title, else its Lobbe's; null when there is none | | `nextCursor` | `string \| null` | Yes | Pass it back as `cursor` for the next, older page; null on the last page | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). ### `GET /recordings/search` Search the owner's finished meetings by their words, newest meeting first. | Property | Value | | --- | --- | | Scopes | `recordings:query` | | Spends minutes | No | | Rate limit | `search`: 120 requests per 10 minutes per token | | Success | `200` | #### Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `q` | `query` | `string` | Yes | The words to find, 1–200 characters. A meeting matches when every word appears in its title, transcript or Lobbe, even inside a longer word; case, diacritics, the half-space (ZWNJ) and Arabic letter forms are ignored, and Persian digits match Latin ones. A `q` with no word in it is a `400` | | `seriesId` | `query` | `string` | No | Only the meetings of this series, one you own or joined (a series you can't read is a `404`); absent = your own meetings | | `kind` | `query` | `"MEETING" \| "LECTURE" \| "INTERVIEW" \| "VOICE_MEMO" \| "CALL"` | No | Only meetings of this kind: `MEETING`, `LECTURE`, `INTERVIEW`, `VOICE_MEMO` or `CALL` | | `from` | `query` | `string (date-time)` | No | Only meetings that happened at or after this time (`recordedAt`), ISO 8601 with an offset | | `to` | `query` | `string (date-time)` | No | Only meetings that happened at or before this time (`recordedAt`), ISO 8601 with an offset | | `limit` | `query` | `integer` | No | How many meetings to return, 1–50 (default 20) | | `cursor` | `query` | `string` | No | The `nextCursor` of the previous page, passed back as is; absent = the first page, and a malformed value is a `400` | #### Response `200` Finished meetings whose words match, newest meeting first (by `recordedAt`); searching is free. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `items[]` | `object[]` | Yes | The meetings on this page: `READY` ones only, each a `listReadyRecordings` item plus its `snippet` | | `items[].id` | `string` | Yes | The meeting's id | | `items[].title` | `string \| null` | Yes | The meeting's title: the one its owner or the submitting token gave it, else its Lobbe's; null when it has none | | `items[].kind` | `"MEETING" \| "LECTURE" \| "INTERVIEW" \| "VOICE_MEMO" \| "CALL"` | Yes | The kind of recording, which picks the Lobbe's template: `MEETING`, `LECTURE`, `INTERVIEW`, `VOICE_MEMO` or `CALL` | | `items[].createdAt` | `string` | Yes | When Lobbe received the meeting (ISO 8601, UTC); `listReadyRecordings` is ordered by it | | `items[].recordedAt` | `string` | Yes | When the meeting happened (ISO 8601, UTC): the `recordedAt` it was submitted with, else when Lobbe received it | | `items[].durationSeconds` | `integer \| null` | Yes | The audio's length in seconds, as Lobbe measured it; null if unknown | | `items[].url` | `string (uri)` | Yes | The meeting's page in the web app | | `items[].bundleUrl` | `string (uri)` | Yes | GET it with a token holding `recordings:read` for the whole meeting (`getRecordingBundle`) | | `items[].series` | `object \| null` | Yes | The series the meeting is filed in; null when it is in none, or while series are switched off | | `items[].series.id` | `string` | Yes | The series' id, as `seriesId` takes it | | `items[].series.name` | `string` | Yes | The series' name | | `items[].displayTitle` | `string \| null` | Yes | The title as the app shows it: in a series titled by date, the series' name and the meeting's date in the Lobbe's language; otherwise the meeting's title, else its Lobbe's; null when there is none | | `items[].snippet` | `string \| null` | Yes | Up to 200 characters around the first match, from the transcript, else from the Lobbe when only it matched; null when the words were found in the title alone | | `nextCursor` | `string \| null` | Yes | Pass it back as `cursor` for the next, older page; null on the last page | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). ### `GET /recordings/{id}/ask/quote` What asking this meeting a question will cost, in minutes. | Property | Value | | --- | --- | | Scopes | `recordings:query` | | Spends minutes | No | | Rate limit | `search`: 120 requests per 10 minutes per token | | Success | `200` | #### Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | `path` | `string` | Yes | The meeting's id | #### Response `200` What one question to this meeting costs; reading the quote is free. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `minutes` | `integer` | Yes | The price in minutes: what `askRecording` charges, worked out the same way from the meeting's transcript, so the two agree while the transcript is unchanged | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). ### `POST /recordings/{id}/ask` Ask one meeting a question, answered from its transcript and Lobbe; each answer is charged. | Property | Value | | --- | --- | | Scopes | `recordings:query` | | Spends minutes | Yes | | Rate limit | `ask`: 30 requests per 10 minutes per token | | Success | `200` | This route can spend minutes: send an `Idempotency-Key` header, and a retry with the same key returns the first outcome instead of charging again. See [Idempotency and retries](/guides/idempotency/). #### Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | `path` | `string` | Yes | The meeting's id | | `Idempotency-Key` | `header` | `string` | No | Recommended. Make one key (8–64 characters, a random UUID say) once per new meeting or question, and send that same key unchanged on every retry of that request; never reuse it for a different request. A replay returns the original outcome and never charges twice. Keys are the owner's for good: they never expire. | #### Request body (`application/json`) The question, with a price ceiling if you want one. A field not listed here is a `400`. | Field | Type | Required | Description | | --- | --- | --- | --- | | `question` | `string` | Yes | The question, trimmed, 1–500 characters | | `maxMinutes` | `integer` | No | The most you agree to pay, in minutes (at least 1): when the price is higher, the question is refused with `409 PRICE_ABOVE_MAX` (its `details` carry `minutes` and `maxMinutes`) and nothing is charged; absent = no ceiling | | `idempotencyKey` | `string` | No | The idempotency key in the body, instead of the `Idempotency-Key` header (8–64 characters); the header wins when both are sent | #### Response `200` The answer, already charged; a replay with the same idempotency key returns it again and never charges twice. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `answer` | `string` | Yes | The answer. `[n]` marks the claim that `citations[n - 1]` supports, numbered 1, 2, … in reading order | | `citations[]` | `object[]` | Yes | Where the meeting says it: at most 3 turns, in the order the answer first cites them | | `citations[].startMs` | `number` | Yes | When the cited turn starts, in milliseconds from the start of the meeting's audio | | `citations[].speaker` | `string \| null` | Yes | Who spoke it: the turn's speaker label as transcribed (e.g. `speaker_0`), never a name given to the voice; null when the turn has none | | `minutes` | `integer` | Yes | What this answer charged, in minutes; a refusal is charged too | | `refused` | `boolean` | Yes | `true` when Lobbe declines to answer from what was said: `answer` says why, and it is still charged | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). ### `GET /recordings/{id}/bundle` One finished meeting: its transcript, its Lobbe and its stats; ?format=markdown returns one Markdown document. | Property | Value | | --- | --- | | Scopes | `recordings:read` | | Spends minutes | No | | Rate limit | `read`: 600 requests per 10 minutes per token | | Success | `200` | Add `?format=markdown` and it answers `text/markdown`: the same content as one Markdown document. #### Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | `path` | `string` | Yes | The meeting's id | | `format` | `query` | `"json" \| "markdown"` | No | `json` (the default) or `markdown`, which answers one `text/markdown` document of the whole meeting; any other value is a `400` | #### Response `200` One finished meeting: its transcript, its Lobbe and its stats. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `recording` | `object` | Yes | The meeting itself | | `recording.id` | `string` | Yes | The meeting's id | | `recording.title` | `string \| null` | Yes | The meeting's title: the one its owner or the submitting token gave it, else its Lobbe's; null when it has none | | `recording.kind` | `"MEETING" \| "LECTURE" \| "INTERVIEW" \| "VOICE_MEMO" \| "CALL"` | Yes | The kind of recording, which picks the Lobbe's template: `MEETING`, `LECTURE`, `INTERVIEW`, `VOICE_MEMO` or `CALL` | | `recording.source` | `"IN_APP" \| "UPLOAD"` | Yes | How it reached Lobbe: `IN_APP` (recorded in the app) or `UPLOAD` (a file, a token's submission included) | | `recording.createdAt` | `string` | Yes | When Lobbe received the meeting (ISO 8601, UTC) | | `recording.recordedAt` | `string` | Yes | When the meeting happened (ISO 8601, UTC): the `recordedAt` it was submitted with, else when Lobbe received it | | `recording.durationSeconds` | `integer \| null` | Yes | The audio's length in seconds, as Lobbe measured it; null if unknown | | `recording.url` | `string (uri)` | Yes | The meeting's page in the web app | | `recording.series` | `object \| null` | Yes | The series the meeting is filed in; null when it is in none, or while series are switched off | | `recording.series.id` | `string` | Yes | The series' id, as `seriesId` takes it | | `recording.series.name` | `string` | Yes | The series' name | | `recording.access` | `"owner" \| "member"` | Yes | `owner` when the meeting is yours; `member` when it belongs to a series you joined | | `recording.displayTitle` | `string \| null` | Yes | The title as the app shows it: in a series titled by date, the series' name and the meeting's date in the Lobbe's language; otherwise the meeting's title, else its Lobbe's; null when there is none | | `recording.subtitle` | `string \| null` | Yes | In a series titled by date, the meeting's own title, else its Lobbe's; null otherwise | | `transcript` | `object` | Yes | What was said, turn by turn | | `transcript.language` | `string` | Yes | The language detected in the audio, as an ISO 639-1 code (`fa`, `en`, …) | | `transcript.revision` | `integer` | Yes | Goes up by one with each owner edit and each time the meeting is checked again for missed voices; `0` = neither has happened | | `transcript.edited` | `boolean` | Yes | Whether the transcript has changed since it was first made, by an owner edit or by checking the meeting again (`revision` above `0`) | | `transcript.speakers[]` | `object[]` | Yes | One row per person who spoke; voices given the same name are one person | | `transcript.speakers[].label` | `string` | Yes | The person's label (e.g. `speaker_0`), as `segments[].speaker` and `stats` carry it | | `transcript.speakers[].name` | `string \| null` | Yes | The name given to this voice; null when nobody named it | | `transcript.segments[]` | `object[]` | Yes | The turns, in order | | `transcript.segments[].speaker` | `string` | Yes | Who spoke: a `speakers[].label` | | `transcript.segments[].startMs` | `integer` | Yes | When the turn starts, in milliseconds from the start of the audio | | `transcript.segments[].endMs` | `integer` | Yes | When the turn ends, in milliseconds from the start of the audio | | `transcript.segments[].text` | `string` | Yes | What was said, with the owner's corrections | | `transcript.segments[].edited` | `boolean` | Yes | Whether the owner corrected this turn | | `transcript.segments[].lowConfidence` | `boolean` | Yes | Whether transcription may have misheard this turn; always `false` on a turn the owner corrected | | `transcript.text` | `string` | Yes | The whole transcript as plain text, with no speakers or times | | `transcript.markdown` | `string` | Yes | The transcript as Markdown, one `**name** [mm:ss]: text` line per turn (`[h:mm:ss]` from one hour on) and no heading; a voice nobody named shows its label | | `lobbe` | `object` | Yes | The meeting's Lobbe: the AI write-up, in its latest version | | `lobbe.id` | `string` | Yes | The Lobbe's id | | `lobbe.type` | `"MEETING_MOM" \| "LECTURE_NOTES" \| "INTERVIEW_QA" \| "VOICE_MEMO" \| "CALL_NOTES"` | Yes | Its template, set by the meeting's `kind`: `MEETING_MOM`, `LECTURE_NOTES`, `INTERVIEW_QA`, `VOICE_MEMO` or `CALL_NOTES` | | `lobbe.language` | `"en" \| "fa"` | Yes | The language the Lobbe is written in: `fa` or `en` | | `lobbe.promptVersion` | `string` | Yes | Which prompt made it: an opaque tag that starts with the prompt's version (e.g. `v8`), for comparing Lobbes, not for parsing | | `lobbe.transcriptRevision` | `integer \| null` | Yes | The transcript `revision` the Lobbe was made from, so below `transcript.revision` means the transcript was edited since; null on Lobbes made before transcript editing (read it as `0`) | | `lobbe.content` | `object` | Yes | The Lobbe's structured content | | `lobbe.content.version` | `2` | Yes | The content format's version: always `2` | | `lobbe.content.title` | `string` | Yes | The Lobbe's own title, 1–200 characters | | `lobbe.content.sections[]` | `object[]` | Yes | The sections the Lobbe is made of, at least one, in reading order; which kinds appear depends on the meeting | | `lobbe.content.sections[] (kind: "summary")` | `object` | | When `kind` is `"summary"`, the fields below | | `lobbe.content.sections[].kind` | `"summary"` | Yes | The section's kind, which decides its other fields: `summary`, `decision_list`, `action_item_list`, `key_point_list`, `qa_list`, `topic_notes`, `follow_up_list` or `free_text` | | `lobbe.content.sections[].text` | `string` | Yes | The section's prose (`summary` and `free_text`) | | `lobbe.content.sections[] (kind: "decision_list")` | `object` | | When `kind` is `"decision_list"`, the fields below | | `lobbe.content.sections[].kind` | `"decision_list"` | Yes | The section's kind, which decides its other fields: `summary`, `decision_list`, `action_item_list`, `key_point_list`, `qa_list`, `topic_notes`, `follow_up_list` or `free_text` | | `lobbe.content.sections[].items[]` | `object[]` | Yes | The section's entries (`decision_list`, `action_item_list`, `key_point_list`, `qa_list` and `follow_up_list`) | | `lobbe.content.sections[].items[].text` | `string` | Yes | The entry itself: a decision, an action, a key point or a follow-up | | `lobbe.content.sections[].items[].sourceMs` | `number \| null` | No | Where the meeting supports the entry: the `startMs` of that transcript turn, in milliseconds; null when no single turn does, absent on Lobbes made before citations, and on Lobbes made before 6 September 2026 it may be the second shown to the model rather than the turn's exact start | | `lobbe.content.sections[] (kind: "action_item_list")` | `object` | | When `kind` is `"action_item_list"`, the fields below | | `lobbe.content.sections[].kind` | `"action_item_list"` | Yes | The section's kind, which decides its other fields: `summary`, `decision_list`, `action_item_list`, `key_point_list`, `qa_list`, `topic_notes`, `follow_up_list` or `free_text` | | `lobbe.content.sections[].items[]` | `object[]` | Yes | The section's entries (`decision_list`, `action_item_list`, `key_point_list`, `qa_list` and `follow_up_list`) | | `lobbe.content.sections[].items[].text` | `string` | Yes | The entry itself: a decision, an action, a key point or a follow-up | | `lobbe.content.sections[].items[].owner` | `string \| null` | Yes | Who takes the action, by a spoken name or a speaker label; null when the meeting does not make it clear | | `lobbe.content.sections[].items[].due` | `string \| null` | Yes | When the action is due, as it was said (never an invented date); null when no time was said | | `lobbe.content.sections[].items[].sourceMs` | `number \| null` | No | Where the meeting supports the entry: the `startMs` of that transcript turn, in milliseconds; null when no single turn does, absent on Lobbes made before citations, and on Lobbes made before 6 September 2026 it may be the second shown to the model rather than the turn's exact start | | `lobbe.content.sections[] (kind: "key_point_list")` | `object` | | When `kind` is `"key_point_list"`, the fields below | | `lobbe.content.sections[].kind` | `"key_point_list"` | Yes | The section's kind, which decides its other fields: `summary`, `decision_list`, `action_item_list`, `key_point_list`, `qa_list`, `topic_notes`, `follow_up_list` or `free_text` | | `lobbe.content.sections[].items[]` | `object[]` | Yes | The section's entries (`decision_list`, `action_item_list`, `key_point_list`, `qa_list` and `follow_up_list`) | | `lobbe.content.sections[].items[].text` | `string` | Yes | The entry itself: a decision, an action, a key point or a follow-up | | `lobbe.content.sections[] (kind: "qa_list")` | `object` | | When `kind` is `"qa_list"`, the fields below | | `lobbe.content.sections[].kind` | `"qa_list"` | Yes | The section's kind, which decides its other fields: `summary`, `decision_list`, `action_item_list`, `key_point_list`, `qa_list`, `topic_notes`, `follow_up_list` or `free_text` | | `lobbe.content.sections[].items[]` | `object[]` | Yes | The section's entries (`decision_list`, `action_item_list`, `key_point_list`, `qa_list` and `follow_up_list`) | | `lobbe.content.sections[].items[].question` | `string` | Yes | A question asked in the meeting (`qa_list`) | | `lobbe.content.sections[].items[].answer` | `string` | Yes | The answer the meeting gave it | | `lobbe.content.sections[] (kind: "topic_notes")` | `object` | | When `kind` is `"topic_notes"`, the fields below | | `lobbe.content.sections[].kind` | `"topic_notes"` | Yes | The section's kind, which decides its other fields: `summary`, `decision_list`, `action_item_list`, `key_point_list`, `qa_list`, `topic_notes`, `follow_up_list` or `free_text` | | `lobbe.content.sections[].topics[]` | `object[]` | Yes | A `topic_notes` section's topics | | `lobbe.content.sections[].topics[].topic` | `string` | Yes | The topic | | `lobbe.content.sections[].topics[].notes` | `string` | Yes | What was said about it | | `lobbe.content.sections[] (kind: "follow_up_list")` | `object` | | When `kind` is `"follow_up_list"`, the fields below | | `lobbe.content.sections[].kind` | `"follow_up_list"` | Yes | The section's kind, which decides its other fields: `summary`, `decision_list`, `action_item_list`, `key_point_list`, `qa_list`, `topic_notes`, `follow_up_list` or `free_text` | | `lobbe.content.sections[].items[]` | `object[]` | Yes | The section's entries (`decision_list`, `action_item_list`, `key_point_list`, `qa_list` and `follow_up_list`) | | `lobbe.content.sections[].items[].text` | `string` | Yes | The entry itself: a decision, an action, a key point or a follow-up | | `lobbe.content.sections[].items[].sourceMs` | `number \| null` | No | Where the meeting supports the entry: the `startMs` of that transcript turn, in milliseconds; null when no single turn does, absent on Lobbes made before citations, and on Lobbes made before 6 September 2026 it may be the second shown to the model rather than the turn's exact start | | `lobbe.content.sections[] (kind: "free_text")` | `object` | | When `kind` is `"free_text"`, the fields below | | `lobbe.content.sections[].kind` | `"free_text"` | Yes | The section's kind, which decides its other fields: `summary`, `decision_list`, `action_item_list`, `key_point_list`, `qa_list`, `topic_notes`, `follow_up_list` or `free_text` | | `lobbe.content.sections[].title` | `string \| null` | Yes | A `free_text` section's own heading; null = the default heading | | `lobbe.content.sections[].text` | `string` | Yes | The section's prose (`summary` and `free_text`) | | `lobbe.markdown` | `string` | Yes | The Lobbe as one standalone Markdown document, headed by the meeting's title as an H1 | | `stats` | `object \| null` | Yes | The meeting's stats, counted from the transcript (no AI) under the labels of `transcript.speakers`; this route always fills it | | `stats.version` | `2` | Yes | The stats format's version: always `2` | | `stats.meetingDurationSeconds` | `number` | Yes | The meeting's length in seconds | | `stats.spokenSeconds` | `number` | Yes | Everyone's speaking time added up, in seconds to one decimal | | `stats.speakerCount` | `integer` | Yes | How many people spoke | | `stats.wordCount` | `integer` | Yes | Words spoken in the meeting | | `stats.turnCount` | `integer` | Yes | Turns in the meeting: runs of one person speaking without another in between | | `stats.questionCount` | `integer` | Yes | Question marks (`؟` or `?`) across all turns | | `stats.longestSilence` | `object \| null` | Yes | The longest gap between two turns, when it is at least 3 seconds; null otherwise | | `stats.longestSilence.seconds` | `number` | Yes | The gap's length in seconds, to one decimal | | `stats.longestSilence.afterSpeaker` | `string` | Yes | Who spoke last before it: a `transcript.speakers[].label` | | `stats.longestSilence.afterQuote` | `string` | Yes | The end of that person's line before the gap, at most 80 characters, cut at a word | | `stats.speakers[]` | `object[]` | Yes | One row per person, most speaking time first | | `stats.speakers[].speaker` | `string` | Yes | The person: a `transcript.speakers[].label` | | `stats.speakers[].talkSeconds` | `number` | Yes | Their speaking time in seconds, to one decimal | | `stats.speakers[].sharePct` | `number` | Yes | Their share of all speaking time, 0–100, to one decimal | | `stats.speakers[].wordCount` | `integer` | Yes | Words they spoke | | `stats.speakers[].wordsPerMinute` | `number \| null` | Yes | Their pace in words per minute; null under 5 seconds of speech | | `stats.speakers[].longestMonologueSeconds` | `number` | Yes | Their longest turn in seconds, to one decimal | | `stats.speakers[].turnCount` | `integer` | Yes | How many turns they took | | `stats.speakers[].avgTurnSeconds` | `number` | Yes | Their average turn in seconds, to one decimal; `0` when they took no timed turn | | `stats.speakers[].questionCount` | `integer` | Yes | Question marks (`؟` or `?`) in their turns | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). ### `GET /recordings/{id}/status` One meeting's progress; at NAMING, the link that asks the owner to name the speakers. | Property | Value | | --- | --- | | Scopes | `recordings:read` or `recordings:write` | | Spends minutes | No | | Rate limit | `read`: 600 requests per 10 minutes per token | | Success | `200` | #### Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | `path` | `string` | Yes | The meeting's id | #### Response `200` One meeting's progress. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `id` | `string` | Yes | The meeting's id | | `status` | `"QUEUED" \| "TRANSCRIBING" \| "NAMING" \| "GENERATING" \| "READY" \| "FAILED"` | Yes | Where the meeting is: `QUEUED`, `TRANSCRIBING`, `NAMING` (waiting for the speakers' names), `GENERATING` (the Lobbe is being made), then `READY`; or `FAILED` | | `title` | `string \| null` | Yes | The meeting's title: the one it was submitted with or its owner gave it, else its Lobbe's once that is made; null until then | | `error` | `object \| null` | Yes | Why the meeting failed; null unless `status` is `FAILED` | | `error.code` | `string` | Yes | The failure's code, e.g. `NO_SPEECH` or `AUDIO_CORRUPT`; `FAILED` when there is no more specific one | | `error.retryable` | `boolean` | Yes | Whether `retryRecording` can help: `false` for a meeting with no speech or damaged audio | | `naming` | `object \| null` | Yes | Naming the speakers; null unless `status` is `NAMING` | | `naming.url` | `string` | Yes | Send it to the owner: it opens the meeting's naming sheet, for the owner alone, signed in | | `naming.deadline` | `string \| null` | Yes | The earliest time Lobbe goes ahead without names (ISO 8601, UTC); null = it waits until the owner names the speakers or skips | | `bundleUrl` | `string \| null` | Yes | The meeting's bundle once it is `READY`, for a token holding `recordings:read`; null otherwise | | `url` | `string` | Yes | The meeting's page in the web app | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). ### `POST /recordings` Submit a meeting's audio (multipart); answers 202 with its status link. | Property | Value | | --- | --- | | Scopes | `recordings:write` | | Spends minutes | Yes | | Rate limit | `submit`: 30 requests per 10 minutes per token | | Success | `202` | This route can spend minutes: send an `Idempotency-Key` header, and a retry with the same key returns the first outcome instead of charging again. See [Idempotency and retries](/guides/idempotency/). #### Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `Idempotency-Key` | `header` | `string` | No | Recommended. Make one key (8–64 characters, a random UUID say) once per new meeting or question, and send that same key unchanged on every retry of that request; never reuse it for a different request. A replay returns the original outcome and never charges twice. Keys are the owner's for good: they never expire. | #### Request body (`multipart/form-data`) A field not listed here is a `400`. | Field | Type | Required | Description | | --- | --- | --- | --- | | `recordedAt` | `string (date-time)` | No | When the meeting happened, ISO 8601 with an offset, at most 5 minutes ahead and not before 2000; absent = now | | `title` | `string` | No | One line: whitespace runs collapse to a space and the ends are trimmed, then 1–120 characters | | `seriesId` | `string` | No | A series you own to file the meeting in; any other id files it in your personal archive, never an error | | `kind` | `"MEETING" \| "LECTURE" \| "INTERVIEW" \| "VOICE_MEMO" \| "CALL"` | No | What was recorded, which picks the Lobbe's template: `MEETING`, `LECTURE`, `INTERVIEW`, `VOICE_MEMO` or `CALL` (default `MEETING`) | | `outputLanguage` | `"MATCH" \| "FA" \| "EN"` | No | The Lobbe's language: `MATCH` (the meeting's own), `FA` or `EN` (default `MATCH`) | | `useKeyterms` | `"on" \| "off"` | No | `on` (the default) transcribes with your vocabulary, and the series' when the meeting is filed in one, adding a 20% surcharge whenever there are words to use; `off` uses none | | `naming` | `"default" \| "skip"` | No | `default` (the default) follows your speaker-naming setting; `skip` makes the Lobbe without waiting for names, unnamed voices taking Lobbe's suggestions | | `audio` | `file` | Yes | The meeting's audio file | | `attachment` | `file` | No | One PDF, PPTX or DOCX the Lobbe reads with the meeting | #### Response `202` The meeting, stored; replaying the request with the same `Idempotency-Key` answers the same meeting. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `recording` | `object` | Yes | The meeting you submitted | | `recording.id` | `string` | Yes | The meeting's id | | `recording.status` | `"QUEUED" \| "TRANSCRIBING" \| "NAMING" \| "GENERATING" \| "READY" \| "FAILED"` | Yes | Where the meeting is: `QUEUED`, `TRANSCRIBING`, `NAMING` (waiting for the speakers' names), `GENERATING` (the Lobbe is being made), then `READY`; or `FAILED` | | `recording.minutes` | `integer` | Yes | What the meeting costs, in minutes: its measured length with the vocabulary surcharge, plus the attachment | | `recording.recordedAt` | `string` | Yes | When the meeting happened (ISO 8601, UTC): the `recordedAt` it was submitted with, else when Lobbe received it | | `recording.statusUrl` | `string` | Yes | GET it with the same token to follow the meeting (`getRecordingStatus`) | | `recording.bundleUrl` | `string \| null` | Yes | The meeting's bundle when it is already `READY` (a replay) and the token holds `recordings:read`; null otherwise | | `recording.url` | `string` | Yes | The meeting's page in the web app | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). ### `POST /recordings/{id}/naming` Skip naming the speakers: unnamed voices take Lobbe's suggestions and the Lobbe is made. | Property | Value | | --- | --- | | Scopes | `recordings:write` | | Spends minutes | No | | Rate limit | `submit`: 30 requests per 10 minutes per token | | Success | `200` | #### Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | `path` | `string` | Yes | The meeting's id | #### Request body (`application/json`) The only naming action a token may take. A field not listed here is a `400`. | Field | Type | Required | Description | | --- | --- | --- | --- | | `action` | `"skip"` | Yes | Always `skip`: make the Lobbe now, unnamed voices taking Lobbe's suggestions | #### Response `200` One meeting's progress. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `id` | `string` | Yes | The meeting's id | | `status` | `"QUEUED" \| "TRANSCRIBING" \| "NAMING" \| "GENERATING" \| "READY" \| "FAILED"` | Yes | Where the meeting is: `QUEUED`, `TRANSCRIBING`, `NAMING` (waiting for the speakers' names), `GENERATING` (the Lobbe is being made), then `READY`; or `FAILED` | | `title` | `string \| null` | Yes | The meeting's title: the one it was submitted with or its owner gave it, else its Lobbe's once that is made; null until then | | `error` | `object \| null` | Yes | Why the meeting failed; null unless `status` is `FAILED` | | `error.code` | `string` | Yes | The failure's code, e.g. `NO_SPEECH` or `AUDIO_CORRUPT`; `FAILED` when there is no more specific one | | `error.retryable` | `boolean` | Yes | Whether `retryRecording` can help: `false` for a meeting with no speech or damaged audio | | `naming` | `object \| null` | Yes | Naming the speakers; null unless `status` is `NAMING` | | `naming.url` | `string` | Yes | Send it to the owner: it opens the meeting's naming sheet, for the owner alone, signed in | | `naming.deadline` | `string \| null` | Yes | The earliest time Lobbe goes ahead without names (ISO 8601, UTC); null = it waits until the owner names the speakers or skips | | `bundleUrl` | `string \| null` | Yes | The meeting's bundle once it is `READY`, for a token holding `recordings:read`; null otherwise | | `url` | `string` | Yes | The meeting's page in the web app | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). ### `POST /recordings/{id}/retry` Retry a failed meeting: the step that failed runs again, at no new charge. | Property | Value | | --- | --- | | Scopes | `recordings:write` | | Spends minutes | No | | Rate limit | `submit`: 30 requests per 10 minutes per token | | Success | `200` | #### Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | `path` | `string` | Yes | The meeting's id | #### Response `200` One meeting's progress. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `id` | `string` | Yes | The meeting's id | | `status` | `"QUEUED" \| "TRANSCRIBING" \| "NAMING" \| "GENERATING" \| "READY" \| "FAILED"` | Yes | Where the meeting is: `QUEUED`, `TRANSCRIBING`, `NAMING` (waiting for the speakers' names), `GENERATING` (the Lobbe is being made), then `READY`; or `FAILED` | | `title` | `string \| null` | Yes | The meeting's title: the one it was submitted with or its owner gave it, else its Lobbe's once that is made; null until then | | `error` | `object \| null` | Yes | Why the meeting failed; null unless `status` is `FAILED` | | `error.code` | `string` | Yes | The failure's code, e.g. `NO_SPEECH` or `AUDIO_CORRUPT`; `FAILED` when there is no more specific one | | `error.retryable` | `boolean` | Yes | Whether `retryRecording` can help: `false` for a meeting with no speech or damaged audio | | `naming` | `object \| null` | Yes | Naming the speakers; null unless `status` is `NAMING` | | `naming.url` | `string` | Yes | Send it to the owner: it opens the meeting's naming sheet, for the owner alone, signed in | | `naming.deadline` | `string \| null` | Yes | The earliest time Lobbe goes ahead without names (ISO 8601, UTC); null = it waits until the owner names the speakers or skips | | `bundleUrl` | `string \| null` | Yes | The meeting's bundle once it is `READY`, for a token holding `recordings:read`; null otherwise | | `url` | `string` | Yes | The meeting's page in the web app | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). --- # Events > The events routes of the Lobbe API Every path is relative to `https://app.lobbe.ai/api/v1`, and every request sends `Authorization: Bearer $LOBBE_TOKEN`. The [API reference](/reference/) lists every route and explains the `x-lobbe-*` fields. :::note Sending meetings, creating and editing series, changing settings, search and Ask through a token are being switched on gradually. If the form in Connections doesn't offer a scope, it isn't available to your account yet. ::: ### `GET /events` What happened to the owner's meetings since a cursor: created, waiting for names, ready, failed, updated, deleted. | Property | Value | | --- | --- | | Scopes | `recordings:read` or `recordings:write` | | Spends minutes | No | | Rate limit | `read`: 600 requests per 10 minutes per token | | Success | `200` | #### Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `after` | `query` | `"latest" \| string` | No | Where to read from: the `nextCursor` of the previous page, passed back as is, or `latest`, which starts from now (no events, and a cursor to poll with). Absent = the oldest event a fresh read serves, about two hours inside the 30-day window events are kept for. A cursor older than that window answers `410 CURSOR_EXPIRED`: take a new cursor with `after=latest` first, then catch up from `listReadyRecordings`, then read on from that new cursor. A malformed value is a `400` | | `limit` | `query` | `integer` | No | How many events to return, 1–500 (default 100) | #### Response `200` What happened to the meetings this token reaches since `after`, oldest first. With `recordings:read`, the owner's own meetings (not those of a series they joined); with only `recordings:write`, the meetings this token submitted. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `events[]` | `object[]` | Yes | The events on this page, oldest first; for one meeting, in the order its changes happened. No event carries a meeting's content: only ids, its status, an error code or the kind of change | | `events[] (type: "recording.created")` | `object` | | A meeting was received | | `events[].id` | `string` | Yes | The event's own id, the same on every read: use it to skip an event you already handled. It is not a cursor (page with `nextCursor`) | | `events[].recordingId` | `string` | Yes | The id of the meeting it is about | | `events[].seriesId` | `string \| null` | Yes | The series the meeting was in when the change happened; null when it was in none, or while series are switched off | | `events[].occurredAt` | `string` | Yes | When the change happened (ISO 8601, UTC); follow the feed's order rather than sorting by it | | `events[].type` | `"recording.created"` | Yes | `recording.created`: the meeting was received in full, whether recorded in the app, uploaded or submitted by a token | | `events[].data` | `object` | Yes | The meeting's `status` as the change left it | | `events[].data.status` | `"QUEUED" \| "TRANSCRIBING" \| "NAMING" \| "GENERATING" \| "READY" \| "FAILED"` | Yes | Where the meeting is: `QUEUED`, `TRANSCRIBING`, `NAMING` (waiting for the speakers' names), `GENERATING` (the Lobbe is being made), then `READY`; or `FAILED` | | `events[] (type: "recording.naming_required")` | `object` | | A meeting is waiting for names | | `events[].id` | `string` | Yes | The event's own id, the same on every read: use it to skip an event you already handled. It is not a cursor (page with `nextCursor`) | | `events[].recordingId` | `string` | Yes | The id of the meeting it is about | | `events[].seriesId` | `string \| null` | Yes | The series the meeting was in when the change happened; null when it was in none, or while series are switched off | | `events[].occurredAt` | `string` | Yes | When the change happened (ISO 8601, UTC); follow the feed's order rather than sorting by it | | `events[].type` | `"recording.naming_required"` | Yes | `recording.naming_required`: the meeting is at `NAMING`, waiting for its speakers' names or a skip (`skipRecordingNaming`); sent again each time naming opens again | | `events[].data` | `object` | Yes | The meeting's `status` as the change left it | | `events[].data.status` | `"QUEUED" \| "TRANSCRIBING" \| "NAMING" \| "GENERATING" \| "READY" \| "FAILED"` | Yes | Where the meeting is: `QUEUED`, `TRANSCRIBING`, `NAMING` (waiting for the speakers' names), `GENERATING` (the Lobbe is being made), then `READY`; or `FAILED` | | `events[] (type: "recording.ready")` | `object` | | A meeting's Lobbe is ready | | `events[].id` | `string` | Yes | The event's own id, the same on every read: use it to skip an event you already handled. It is not a cursor (page with `nextCursor`) | | `events[].recordingId` | `string` | Yes | The id of the meeting it is about | | `events[].seriesId` | `string \| null` | Yes | The series the meeting was in when the change happened; null when it was in none, or while series are switched off | | `events[].occurredAt` | `string` | Yes | When the change happened (ISO 8601, UTC); follow the feed's order rather than sorting by it | | `events[].type` | `"recording.ready"` | Yes | `recording.ready`: the meeting's Lobbe was made for the first time and its bundle can be read; a later regeneration is `recording.updated` with `regenerated`, never a second `recording.ready` | | `events[].data` | `object` | Yes | The meeting's `status` as the change left it | | `events[].data.status` | `"QUEUED" \| "TRANSCRIBING" \| "NAMING" \| "GENERATING" \| "READY" \| "FAILED"` | Yes | Where the meeting is: `QUEUED`, `TRANSCRIBING`, `NAMING` (waiting for the speakers' names), `GENERATING` (the Lobbe is being made), then `READY`; or `FAILED` | | `events[] (type: "recording.failed")` | `object` | | A meeting failed | | `events[].id` | `string` | Yes | The event's own id, the same on every read: use it to skip an event you already handled. It is not a cursor (page with `nextCursor`) | | `events[].recordingId` | `string` | Yes | The id of the meeting it is about | | `events[].seriesId` | `string \| null` | Yes | The series the meeting was in when the change happened; null when it was in none, or while series are switched off | | `events[].occurredAt` | `string` | Yes | When the change happened (ISO 8601, UTC); follow the feed's order rather than sorting by it | | `events[].type` | `"recording.failed"` | Yes | `recording.failed`: the meeting failed for good; a failure Lobbe still retries on its own is never sent. After `retryRecording` the meeting moves on with no event of its own, and its next event follows | | `events[].data` | `object` | Yes | The meeting's `status` and why it failed | | `events[].data.status` | `"QUEUED" \| "TRANSCRIBING" \| "NAMING" \| "GENERATING" \| "READY" \| "FAILED"` | Yes | Where the meeting is: `QUEUED`, `TRANSCRIBING`, `NAMING` (waiting for the speakers' names), `GENERATING` (the Lobbe is being made), then `READY`; or `FAILED` | | `events[].data.error` | `object` | Yes | Why the meeting failed | | `events[].data.error.code` | `string` | Yes | The failure's code, e.g. `NO_SPEECH` or `AUDIO_CORRUPT` | | `events[].data.error.retryable` | `boolean` | Yes | Whether `retryRecording` can help: `false` for a meeting with no speech or damaged audio | | `events[] (type: "recording.updated")` | `object` | | A meeting changed: a finished one, or a deleted one that came back | | `events[].id` | `string` | Yes | The event's own id, the same on every read: use it to skip an event you already handled. It is not a cursor (page with `nextCursor`) | | `events[].recordingId` | `string` | Yes | The id of the meeting it is about | | `events[].seriesId` | `string \| null` | Yes | The series the meeting was in when the change happened; null when it was in none, or while series are switched off | | `events[].occurredAt` | `string` | Yes | When the change happened (ISO 8601, UTC); follow the feed's order rather than sorting by it | | `events[].type` | `"recording.updated"` | Yes | `recording.updated`: a `READY` meeting changed, and `data.change` says how; an edit to a meeting still in progress is carried by its `recording.ready`. The exception is `restored`, which follows a `recording.deleted`: the meeting came back at the status it had, so its `data.status` can be any status | | `events[].data` | `object` | Yes | The meeting's `status` and the kind of change | | `events[].data.status` | `"QUEUED" \| "TRANSCRIBING" \| "NAMING" \| "GENERATING" \| "READY" \| "FAILED"` | Yes | Where the meeting is: `QUEUED`, `TRANSCRIBING`, `NAMING` (waiting for the speakers' names), `GENERATING` (the Lobbe is being made), then `READY`; or `FAILED` | | `events[].data.change` | `"regenerated" \| "renamed" \| "transcript_edited" \| "speakers_renamed" \| "series_changed" \| "restored"` | Yes | What kind of change, never the change itself: `regenerated` (the Lobbe was made again), `renamed` (the title changed), `transcript_edited` (the owner corrected the transcript), `speakers_renamed` (a speaker's name changed), `series_changed` (the meeting moved into, out of or between series) or `restored` (a deleted meeting is back) | | `events[] (type: "recording.deleted")` | `object` | | A meeting was deleted | | `events[].id` | `string` | Yes | The event's own id, the same on every read: use it to skip an event you already handled. It is not a cursor (page with `nextCursor`) | | `events[].recordingId` | `string` | Yes | The id of the meeting it is about | | `events[].seriesId` | `string \| null` | Yes | The series the meeting was in when the change happened; null when it was in none, or while series are switched off | | `events[].occurredAt` | `string` | Yes | When the change happened (ISO 8601, UTC); follow the feed's order rather than sorting by it | | `events[].type` | `"recording.deleted"` | Yes | `recording.deleted`: the meeting was deleted, at any status; its bundle answers `404` from then on, unless it is restored | | `events[].data` | `object` | Yes | The meeting's `status` as the change left it | | `events[].data.status` | `"QUEUED" \| "TRANSCRIBING" \| "NAMING" \| "GENERATING" \| "READY" \| "FAILED"` | Yes | Where the meeting is: `QUEUED`, `TRANSCRIBING`, `NAMING` (waiting for the speakers' names), `GENERATING` (the Lobbe is being made), then `READY`; or `FAILED` | | `nextCursor` | `string` | Yes | Always set: pass it back as `after` for what happens next. A page that has caught up gets a cursor at now, so polling an account with nothing new never ages into `410` | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). --- # Series > The series routes of the Lobbe API Every path is relative to `https://app.lobbe.ai/api/v1`, and every request sends `Authorization: Bearer $LOBBE_TOKEN`. The [API reference](/reference/) lists every route and explains the `x-lobbe-*` fields. :::note Sending meetings, creating and editing series, changing settings, search and Ask through a token are being switched on gradually. If the form in Connections doesn't offer a scope, it isn't available to your account yet. ::: ### `GET /series/readable` The series you own or joined. | Property | Value | | --- | --- | | Scopes | `series:read` | | Spends minutes | No | | Rate limit | `read`: 600 requests per 10 minutes per token | | Success | `200` | #### Response `200` The series you own, then the ones you joined, each group by name; empty while series are switched off. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `items[]` | `object[]` | Yes | The series | | `items[].id` | `string` | Yes | The series' id, as `seriesId` takes it | | `items[].name` | `string` | Yes | The series' name | | `items[].role` | `"owner" \| "member"` | Yes | `owner` when you made the series; `member` when you joined it | | `items[].meetingCount` | `integer` | Yes | How many finished meetings `meetingsUrl` lists for you right now | | `items[].url` | `string (uri)` | Yes | The series' page in the web app | | `items[].meetingsUrl` | `string (uri)` | Yes | GET it with the same token for the series' finished meetings (`listReadyRecordings` with `seriesId`) | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). ### `GET /series/{id}` One series you own or joined: its name, its defaults and how many finished meetings it holds. | Property | Value | | --- | --- | | Scopes | `series:read` | | Spends minutes | No | | Rate limit | `read`: 600 requests per 10 minutes per token | | Success | `200` | #### Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | `path` | `string` | Yes | The series' id | #### Response `200` One series. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `series` | `object` | Yes | The series. `defaultKind` and `defaultOutputLanguage` are what the app's "Lobbe settings" start with for a meeting filed in it; `submitRecording` does not read them | | `series.id` | `string` | Yes | The series' id, as `seriesId` takes it | | `series.name` | `string` | Yes | The series' name | | `series.access` | `"owner" \| "member"` | Yes | `owner` when you made the series; `member` when you joined it | | `series.defaultKind` | `"MEETING" \| "LECTURE" \| "INTERVIEW" \| "VOICE_MEMO" \| "CALL"` | Yes | The kind of recording, which picks the Lobbe's template: `MEETING`, `LECTURE`, `INTERVIEW`, `VOICE_MEMO` or `CALL` | | `series.defaultOutputLanguage` | `"MATCH" \| "FA" \| "EN"` | Yes | A Lobbe language: `MATCH` (the meeting's own), `FA` or `EN` | | `series.titleStyle` | `"SESSION" \| "TOPIC"` | Yes | How the series' meetings are titled: `SESSION` ("Series name + date": the series' name and the meeting's date, with the meeting's own title beneath) or `TOPIC` ("Each meeting's topic": its own title) | | `series.dateCalendar` | `"AUTO" \| "JALALI" \| "GREGORIAN"` | Yes | The calendar of the date in a `SESSION` title: `AUTO` (by the Lobbe's language: Solar Hijri for Persian, Gregorian for English), `JALALI` (Solar Hijri) or `GREGORIAN` | | `series.meetingCount` | `integer` | Yes | How many finished meetings of the series you can read: the ones `listReadyRecordings` with `seriesId` lists | | `series.lastRecordedAt` | `string \| null` | Yes | When the newest of those meetings happened (ISO 8601, UTC); null when there is none | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). ### `PATCH /series/{id}` Change a series you own: its name, type and default language, title style or calendar. | Property | Value | | --- | --- | | Scopes | `series:write` | | Spends minutes | No | | Rate limit | `settings`: 60 requests per 10 minutes per token | | Success | `200` | #### Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | `path` | `string` | Yes | The series' id | #### Request body (`application/json`) The fields to change, at least one; a field you leave out keeps its value. Owner only: a series that isn't yours is a `404`. A name one of your series already has (ignoring case) is `409 SERIES_NAME_TAKEN`. Send at least one field; an empty body is a `400`. A field not listed here is a `400`. | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | `string` | No | One line: whitespace runs (line breaks and tabs included) collapse to a single space and the ends are trimmed, then 1–60 characters; any other control character is refused | | `defaultKind` | `"MEETING" \| "LECTURE" \| "INTERVIEW" \| "VOICE_MEMO" \| "CALL"` | No | The kind the app's "Lobbe settings" start with for a meeting filed in the series: `MEETING`, `LECTURE`, `INTERVIEW`, `VOICE_MEMO` or `CALL` | | `defaultOutputLanguage` | `"MATCH" \| "FA" \| "EN"` | No | The Lobbe language the app's "Lobbe settings" start with for a meeting filed in the series: `MATCH` (the meeting's own), `FA` or `EN` | | `titleStyle` | `"SESSION" \| "TOPIC"` | No | How the series' meetings are titled: `SESSION` ("Series name + date": the series' name and the meeting's date, with the meeting's own title beneath) or `TOPIC` ("Each meeting's topic": its own title) | | `dateCalendar` | `"AUTO" \| "JALALI" \| "GREGORIAN"` | No | The calendar of the date in a `SESSION` title: `AUTO` (by the Lobbe's language: Solar Hijri for Persian, Gregorian for English), `JALALI` (Solar Hijri) or `GREGORIAN` | #### Response `200` One series. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `series` | `object` | Yes | The series. `defaultKind` and `defaultOutputLanguage` are what the app's "Lobbe settings" start with for a meeting filed in it; `submitRecording` does not read them | | `series.id` | `string` | Yes | The series' id, as `seriesId` takes it | | `series.name` | `string` | Yes | The series' name | | `series.access` | `"owner" \| "member"` | Yes | `owner` when you made the series; `member` when you joined it | | `series.defaultKind` | `"MEETING" \| "LECTURE" \| "INTERVIEW" \| "VOICE_MEMO" \| "CALL"` | Yes | The kind of recording, which picks the Lobbe's template: `MEETING`, `LECTURE`, `INTERVIEW`, `VOICE_MEMO` or `CALL` | | `series.defaultOutputLanguage` | `"MATCH" \| "FA" \| "EN"` | Yes | A Lobbe language: `MATCH` (the meeting's own), `FA` or `EN` | | `series.titleStyle` | `"SESSION" \| "TOPIC"` | Yes | How the series' meetings are titled: `SESSION` ("Series name + date": the series' name and the meeting's date, with the meeting's own title beneath) or `TOPIC` ("Each meeting's topic": its own title) | | `series.dateCalendar` | `"AUTO" \| "JALALI" \| "GREGORIAN"` | Yes | The calendar of the date in a `SESSION` title: `AUTO` (by the Lobbe's language: Solar Hijri for Persian, Gregorian for English), `JALALI` (Solar Hijri) or `GREGORIAN` | | `series.meetingCount` | `integer` | Yes | How many finished meetings of the series you can read: the ones `listReadyRecordings` with `seriesId` lists | | `series.lastRecordedAt` | `string \| null` | Yes | When the newest of those meetings happened (ISO 8601, UTC); null when there is none | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). ### `POST /series` Create a series: its name, type and default language, title style and calendar. | Property | Value | | --- | --- | | Scopes | `series:write` | | Spends minutes | No | | Rate limit | `settings`: 60 requests per 10 minutes per token | | Success | `201` | #### Request body (`application/json`) A new series; only `name` is required. A name one of your series already has (ignoring case) is `409 SERIES_NAME_TAKEN`, and an account that already owns 50 series gets `409 SERIES_LIMIT`. A field not listed here is a `400`. | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | `string` | Yes | One line: whitespace runs (line breaks and tabs included) collapse to a single space and the ends are trimmed, then 1–60 characters; any other control character is refused | | `defaultKind` | `"MEETING" \| "LECTURE" \| "INTERVIEW" \| "VOICE_MEMO" \| "CALL"` | No | The kind the app's "Lobbe settings" start with for a meeting filed in the series: `MEETING`, `LECTURE`, `INTERVIEW`, `VOICE_MEMO` or `CALL` (default `MEETING`) | | `defaultOutputLanguage` | `"MATCH" \| "FA" \| "EN"` | No | The Lobbe language the app's "Lobbe settings" start with for a meeting filed in the series: `MATCH` (the meeting's own), `FA` or `EN` (default `MATCH`) | | `titleStyle` | `"SESSION" \| "TOPIC"` | No | How the series' meetings are titled: `SESSION` ("Series name + date": the series' name and the meeting's date, with the meeting's own title beneath) or `TOPIC` ("Each meeting's topic": its own title) (default `SESSION`) | | `dateCalendar` | `"AUTO" \| "JALALI" \| "GREGORIAN"` | No | The calendar of the date in a `SESSION` title: `AUTO` (by the Lobbe's language: Solar Hijri for Persian, Gregorian for English), `JALALI` (Solar Hijri) or `GREGORIAN` (default `AUTO`) | #### Response `201` One series. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `series` | `object` | Yes | The series. `defaultKind` and `defaultOutputLanguage` are what the app's "Lobbe settings" start with for a meeting filed in it; `submitRecording` does not read them | | `series.id` | `string` | Yes | The series' id, as `seriesId` takes it | | `series.name` | `string` | Yes | The series' name | | `series.access` | `"owner" \| "member"` | Yes | `owner` when you made the series; `member` when you joined it | | `series.defaultKind` | `"MEETING" \| "LECTURE" \| "INTERVIEW" \| "VOICE_MEMO" \| "CALL"` | Yes | The kind of recording, which picks the Lobbe's template: `MEETING`, `LECTURE`, `INTERVIEW`, `VOICE_MEMO` or `CALL` | | `series.defaultOutputLanguage` | `"MATCH" \| "FA" \| "EN"` | Yes | A Lobbe language: `MATCH` (the meeting's own), `FA` or `EN` | | `series.titleStyle` | `"SESSION" \| "TOPIC"` | Yes | How the series' meetings are titled: `SESSION` ("Series name + date": the series' name and the meeting's date, with the meeting's own title beneath) or `TOPIC` ("Each meeting's topic": its own title) | | `series.dateCalendar` | `"AUTO" \| "JALALI" \| "GREGORIAN"` | Yes | The calendar of the date in a `SESSION` title: `AUTO` (by the Lobbe's language: Solar Hijri for Persian, Gregorian for English), `JALALI` (Solar Hijri) or `GREGORIAN` | | `series.meetingCount` | `integer` | Yes | How many finished meetings of the series you can read: the ones `listReadyRecordings` with `seriesId` lists | | `series.lastRecordedAt` | `string \| null` | Yes | When the newest of those meetings happened (ISO 8601, UTC); null when there is none | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). ### `GET /series/{id}/keyterms` A series' vocabulary (owner only), and whether the account's vocabulary applies too. | Property | Value | | --- | --- | | Scopes | `series:read` | | Spends minutes | No | | Rate limit | `read`: 600 requests per 10 minutes per token | | Success | `200` | #### Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | `path` | `string` | Yes | The series' id | #### Response `200` The series' vocabulary. Owner only: a series that isn't yours is a `404`. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `keyterms[]` | `string[]` | Yes | The series' own terms, in their saved order (at most 100); a meeting filed in the series is transcribed with them first | | `inheritAccountKeyterms` | `boolean` | Yes | Whether the account's vocabulary is used too, after the series' words and up to 100 terms in all; account terms that don't fit are left off the end of its list (the app's "Also use my vocabulary") | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). ### `PUT /series/{id}/keyterms` Replace a series' vocabulary (owner only); a meeting that uses vocabulary carries its surcharge. | Property | Value | | --- | --- | | Scopes | `series:write` | | Spends minutes | No | | Rate limit | `settings`: 60 requests per 10 minutes per token | | Success | `200` | #### Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | `path` | `string` | Yes | The series' id | #### Request body (`application/json`) The series' whole vocabulary, replacing the old one. A meeting keeps the words it was submitted with, and a meeting that uses any costs 20% more. A field not listed here is a `400`. | Field | Type | Required | Description | | --- | --- | --- | --- | | `keyterms[]` | `string[]` | Yes | The whole new list. Each term is trimmed and its runs of spaces collapse to one; then at most 100 terms, each 1–49 characters and at most 5 words, none containing `<`, `>`, `{`, `}`, `[`, `]` or `\`, and no term twice. A list that breaks a rule is a `400` whose message names the first problem | | `inheritAccountKeyterms` | `boolean` | Yes | Whether the account's vocabulary is used too, after the series' words and up to 100 terms in all; account terms that don't fit are left off the end of its list (the app's "Also use my vocabulary") | #### Response `200` The series' vocabulary. Owner only: a series that isn't yours is a `404`. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `keyterms[]` | `string[]` | Yes | The series' own terms, in their saved order (at most 100); a meeting filed in the series is transcribed with them first | | `inheritAccountKeyterms` | `boolean` | Yes | Whether the account's vocabulary is used too, after the series' words and up to 100 terms in all; account terms that don't fit are left off the end of its list (the app's "Also use my vocabulary") | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). ### `GET /series/{id}/ask/quote` What asking this series a question will cost, in minutes, and over how many meetings. | Property | Value | | --- | --- | | Scopes | `series:query` | | Spends minutes | No | | Rate limit | `search`: 120 requests per 10 minutes per token | | Success | `200` | #### Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | `path` | `string` | Yes | The series' id | #### Response `200` What one question to this series costs; reading the quote is free. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `minutes` | `integer` | Yes | The price in minutes: what `askSeries` charges, worked out the same way, so the two agree while the series' meetings are unchanged | | `meetingCount` | `integer` | Yes | How many finished meetings of the series the question reads: every one's Lobbe, then as many full transcripts as fit, newest first. Under 2 the quote answers `409 SERIES_ASK_TOO_FEW` | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). ### `POST /series/{id}/ask` Ask a whole series a question, answered across its finished meetings; each answer is charged. | Property | Value | | --- | --- | | Scopes | `series:query` | | Spends minutes | Yes | | Rate limit | `ask`: 30 requests per 10 minutes per token | | Success | `200` | This route can spend minutes: send an `Idempotency-Key` header, and a retry with the same key returns the first outcome instead of charging again. See [Idempotency and retries](/guides/idempotency/). #### Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | `path` | `string` | Yes | The series' id | | `Idempotency-Key` | `header` | `string` | No | Recommended. Make one key (8–64 characters, a random UUID say) once per new meeting or question, and send that same key unchanged on every retry of that request; never reuse it for a different request. A replay returns the original outcome and never charges twice. Keys are the owner's for good: they never expire. | #### Request body (`application/json`) The question, with a price ceiling if you want one. A field not listed here is a `400`. | Field | Type | Required | Description | | --- | --- | --- | --- | | `question` | `string` | Yes | The question, trimmed, 1–500 characters | | `maxMinutes` | `integer` | No | The most you agree to pay, in minutes (at least 1): when the price is higher, the question is refused with `409 PRICE_ABOVE_MAX` (its `details` carry `minutes` and `maxMinutes`) and nothing is charged; absent = no ceiling | | `idempotencyKey` | `string` | No | The idempotency key in the body, instead of the `Idempotency-Key` header (8–64 characters); the header wins when both are sent | #### Response `200` The answer across the series' meetings, already charged; a replay with the same idempotency key returns it again and never charges twice. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `answer` | `string` | Yes | The answer. `[n]` marks the claim that `citations[n - 1]` supports, numbered 1, 2, … in reading order | | `citations[]` | `object[]` | Yes | Where the series says it: at most 5 turns, from any of its meetings, in the order the answer first cites them | | `citations[].startMs` | `number` | Yes | When the cited turn starts, in milliseconds from the start of the meeting's audio | | `citations[].speaker` | `string \| null` | Yes | Who spoke it: the turn's speaker label as transcribed (e.g. `speaker_0`), never a name given to the voice; null when the turn has none | | `citations[].recordingId` | `string` | Yes | The meeting the turn is from, one of the series' meetings | | `minutes` | `integer` | Yes | What this answer charged, in minutes; a refusal is charged too | | `refused` | `boolean` | Yes | `true` when Lobbe declines to answer from what was said: `answer` says why, and it is still charged | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). --- # Account > The account routes of the Lobbe API Every path is relative to `https://app.lobbe.ai/api/v1`, and every request sends `Authorization: Bearer $LOBBE_TOKEN`. The [API reference](/reference/) lists every route and explains the `x-lobbe-*` fields. :::note Sending meetings, creating and editing series, changing settings, search and Ask through a token are being switched on gradually. If the form in Connections doesn't offer a scope, it isn't available to your account yet. ::: ### `GET /account` The account: who owns it, its wallet balance in minutes, and its settings. | Property | Value | | --- | --- | | Scopes | `account:read` | | Spends minutes | No | | Rate limit | `read`: 600 requests per 10 minutes per token | | Success | `200` | #### Response `200` The account the token acts for: its owner, balance and settings; never its phone number or email address. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `owner` | `object` | Yes | Who the token acts for: the account's owner | | `owner.type` | `"user"` | Yes | The owner's kind: `user` today; ignore a type you don't know | | `owner.id` | `string` | Yes | The owner's account id | | `owner.displayName` | `string \| null` | Yes | The owner's name; null when none was set (a phone number is never sent) | | `wallet` | `object` | Yes | The account's balance | | `wallet.balanceMinutes` | `integer` | Yes | The balance in minutes; a meeting not yet transcribed is not taken out of it yet, and a first meeting over the balance can leave it below 0 | | `settings` | `object` | Yes | The account's settings a token can see; `updateAccountSettings` changes the first three | | `settings.speakerNamingMode` | `"AUTO" \| "FORCE_ON" \| "FORCE_OFF"` | Yes | How a new meeting waits for its speakers' names: `FORCE_ON` ("Ask") holds it at `NAMING` until they are named or skipped, or its `naming.deadline` passes; `AUTO` ("Ask, but don't wait") holds it a short while, then goes on; `FORCE_OFF` ("Auto-guess") never holds it, and Lobbe guesses the names where it can | | `settings.memoryLearningEnabled` | `boolean` | Yes | Whether Lobbe learns new notes into its memory from each new meeting (the app's "Learn from new meetings"); `false` stops the learning only, and the notes already kept are still used | | `settings.summaryEmailEnabled` | `boolean` | Yes | Whether each new Lobbe is emailed to the owner with its PDF (the app's "Email me after each meeting"); a Lobbe made again is not sent again, and nothing is sent while the account has no email address (only the app can set one) | | `settings.uiLocale` | `"en" \| "fa"` | Yes | The app's language for the account, `en` or `fa`: set in the app, never through a token | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). ### `GET /account/keyterms` The account's vocabulary: words transcription should recognize. | Property | Value | | --- | --- | | Scopes | `account:read` | | Spends minutes | No | | Rate limit | `read`: 600 requests per 10 minutes per token | | Success | `200` | #### Response `200` The account's vocabulary (the app's "My vocabulary"). | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `keyterms[]` | `string[]` | Yes | The terms transcription should recognize, in their saved order (at most 100). A meeting transcribed with vocabulary uses them when it is in no series, and after the series' own terms when its series has `inheritAccountKeyterms: true`; a meeting in a series without it never uses them | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). ### `PUT /account/keyterms` Replace the account's vocabulary; a meeting that uses vocabulary carries its surcharge. | Property | Value | | --- | --- | | Scopes | `account:write` | | Spends minutes | No | | Rate limit | `settings`: 60 requests per 10 minutes per token | | Success | `200` | #### Request body (`application/json`) The account's whole vocabulary, replacing the old one. A meeting keeps the words it was submitted with, and a meeting that uses any costs 20% more. A field not listed here is a `400`. | Field | Type | Required | Description | | --- | --- | --- | --- | | `keyterms[]` | `string[]` | Yes | The whole new list. Each term is trimmed and its runs of spaces collapse to one; then at most 100 terms, each 1–49 characters and at most 5 words, none containing `<`, `>`, `{`, `}`, `[`, `]` or `\`, and no term twice. A list that breaks a rule is a `400` whose message names the first problem | #### Response `200` The account's vocabulary (the app's "My vocabulary"). | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `keyterms[]` | `string[]` | Yes | The terms transcription should recognize, in their saved order (at most 100). A meeting transcribed with vocabulary uses them when it is in no series, and after the series' own terms when its series has `inheritAccountKeyterms: true`; a meeting in a series without it never uses them | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). ### `GET /account/memory` What Lobbe remembers about the account's owner, as the settings screen lists it. | Property | Value | | --- | --- | | Scopes | `account:read` | | Spends minutes | No | | Rate limit | `read`: 600 requests per 10 minutes per token | | Success | `200` | #### Response `200` What Lobbe remembers about the owner to make each Lobbe more accurate, as the app's "Lobbe's memory" lists it. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `memoryEnabled` | `boolean` | Yes | Whether memory is switched on for Lobbe; while `false`, `items` is empty and editing or deleting a note answers `409 MEMORY_DISABLED` | | `items[]` | `object[]` | Yes | The notes. One memory note | | `items[].id` | `string` | Yes | The note's id, as `updateMemoryItem` and `deleteMemoryItem` take it | | `items[].category` | `"ABOUT_YOU" \| "PEOPLE" \| "TERMS"` | Yes | The section it is in: `ABOUT_YOU` ("About you"), `PEOPLE` ("People you meet with") or `TERMS` ("Your terminology") | | `items[].text` | `string` | Yes | The note itself | | `items[].meetingCount` | `integer` | Yes | How many meetings it was learned from | | `items[].pinned` | `boolean` | Yes | `true` once the owner edited it: from then on Lobbe never rewrites or removes it | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). ### `PATCH /account/settings` Change speaker naming, memory learning or the summary email; nothing else. | Property | Value | | --- | --- | | Scopes | `account:write` | | Spends minutes | No | | Rate limit | `settings`: 60 requests per 10 minutes per token | | Success | `200` | #### Request body (`application/json`) The settings to change, at least one: a field you leave out keeps its value, and any other field is a `400` with nothing written. Send at least one field; an empty body is a `400`. A field not listed here is a `400`. | Field | Type | Required | Description | | --- | --- | --- | --- | | `speakerNamingMode` | `"AUTO" \| "FORCE_ON" \| "FORCE_OFF"` | No | How a new meeting waits for its speakers' names: `FORCE_ON` ("Ask") holds it at `NAMING` until they are named or skipped, or its `naming.deadline` passes; `AUTO` ("Ask, but don't wait") holds it a short while, then goes on; `FORCE_OFF` ("Auto-guess") never holds it, and Lobbe guesses the names where it can | | `memoryLearningEnabled` | `boolean` | No | Whether Lobbe learns new notes into its memory from each new meeting (the app's "Learn from new meetings"); `false` stops the learning only, and the notes already kept are still used | | `summaryEmailEnabled` | `boolean` | No | Whether each new Lobbe is emailed to the owner with its PDF (the app's "Email me after each meeting"); a Lobbe made again is not sent again, and nothing is sent while the account has no email address (only the app can set one) | #### Response `200` The account the token acts for: its owner, balance and settings; never its phone number or email address. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `owner` | `object` | Yes | Who the token acts for: the account's owner | | `owner.type` | `"user"` | Yes | The owner's kind: `user` today; ignore a type you don't know | | `owner.id` | `string` | Yes | The owner's account id | | `owner.displayName` | `string \| null` | Yes | The owner's name; null when none was set (a phone number is never sent) | | `wallet` | `object` | Yes | The account's balance | | `wallet.balanceMinutes` | `integer` | Yes | The balance in minutes; a meeting not yet transcribed is not taken out of it yet, and a first meeting over the balance can leave it below 0 | | `settings` | `object` | Yes | The account's settings a token can see; `updateAccountSettings` changes the first three | | `settings.speakerNamingMode` | `"AUTO" \| "FORCE_ON" \| "FORCE_OFF"` | Yes | How a new meeting waits for its speakers' names: `FORCE_ON` ("Ask") holds it at `NAMING` until they are named or skipped, or its `naming.deadline` passes; `AUTO` ("Ask, but don't wait") holds it a short while, then goes on; `FORCE_OFF` ("Auto-guess") never holds it, and Lobbe guesses the names where it can | | `settings.memoryLearningEnabled` | `boolean` | Yes | Whether Lobbe learns new notes into its memory from each new meeting (the app's "Learn from new meetings"); `false` stops the learning only, and the notes already kept are still used | | `settings.summaryEmailEnabled` | `boolean` | Yes | Whether each new Lobbe is emailed to the owner with its PDF (the app's "Email me after each meeting"); a Lobbe made again is not sent again, and nothing is sent while the account has no email address (only the app can set one) | | `settings.uiLocale` | `"en" \| "fa"` | Yes | The app's language for the account, `en` or `fa`: set in the app, never through a token | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). ### `PATCH /account/memory/{id}` Rewrite one memory item; an edited item is kept as you wrote it. | Property | Value | | --- | --- | | Scopes | `account:write` | | Spends minutes | No | | Rate limit | `settings`: 60 requests per 10 minutes per token | | Success | `200` | #### Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | `path` | `string` | Yes | The memory item's id | #### Request body (`application/json`) The note's new text; the note is then pinned, kept as you wrote it. A field not listed here is a `400`. | Field | Type | Required | Description | | --- | --- | --- | --- | | `text` | `string` | Yes | The new text, trimmed: at least 1 character and at most the app's limit for a note (never more than 1000); longer is a `400`, and the text of another note in the same section is `409 MEMORY_DUPLICATE` | #### Response `200` The note as it now reads. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `item` | `object` | Yes | One memory note | | `item.id` | `string` | Yes | The note's id, as `updateMemoryItem` and `deleteMemoryItem` take it | | `item.category` | `"ABOUT_YOU" \| "PEOPLE" \| "TERMS"` | Yes | The section it is in: `ABOUT_YOU` ("About you"), `PEOPLE` ("People you meet with") or `TERMS` ("Your terminology") | | `item.text` | `string` | Yes | The note itself | | `item.meetingCount` | `integer` | Yes | How many meetings it was learned from | | `item.pinned` | `boolean` | Yes | `true` once the owner edited it: from then on Lobbe never rewrites or removes it | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). ### `DELETE /account/memory/{id}` Delete one memory item. | Property | Value | | --- | --- | | Scopes | `account:write` | | Spends minutes | No | | Rate limit | `settings`: 60 requests per 10 minutes per token | | Success | `200` | #### Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | `path` | `string` | Yes | The memory item's id | #### Response `200` The note is gone for good; a later meeting may teach it again. | Field | Type | Always present | Description | | --- | --- | --- | --- | | `schemaVersion` | `1` | Yes | Always `1` within API v1 | | `id` | `string` | Yes | The deleted note's id | | `deleted` | `true` | Yes | Always `true` | Errors answer `{ error: { code, message, details? } }`, and `code` is the contract: see [Errors](/guides/errors/). --- # Changelog > What changed in the Lobbe API, newest first. What changed for a caller of the API, newest first. What may change within v1 is in [Versioning](/reference/versioning/). ## 2026-10-19 ### The docs site - These docs, at `docs.lobbe.ai`, in English and in Persian. The [reference](/reference/) is generated from the same list of routes the API is built from. - For agents: [`llms.txt`](https://docs.lobbe.ai/llms.txt), [`llms-full.txt`](https://docs.lobbe.ai/llms-full.txt), every page as Markdown, and a copy of the OpenAPI document at [`/openapi.json`](https://docs.lobbe.ai/openapi.json). See [Connect an AI agent](/tools/ai-agents/). - `openapi.json` now describes every field and parameter, in English (`description`) and in Persian (`x-lobbe-description-fa`). `x-lobbe-rate-limit` names the route's rate class (`class`), and the bodies of `PATCH /series/{id}` and `PATCH /account/settings` say they need at least one field (`minProperties: 1`). - The Markdown bundle writes the title, the subtitle, the series name and each speaker's name on one line: each run of white space in them, line breaks included, becomes a single space. ### Developer mode - Tokens now need **Developer mode**, in the app under **Account** › **Developers**. It is off for a new account, and it was turned on for every account that had a token that wasn't revoked. - While it is off, every request with a token answers `403 DEVELOPER_MODE_OFF`. The tokens are paused, never revoked: once it is back on, they work again. Tell the person, and don't retry in a loop. See [Errors](/guides/errors/#token_invalid-and-developer_mode_off). - A revoked or expired token still answers `401 TOKEN_INVALID`: the token is checked before the mode. ### Connections - Tokens are made and managed in **Connections**, which replaces the token page in Settings. There you pick the scopes on offer, set a daily cap, follow each token's spending and requests, and copy the setup for an AI agent, n8n or `curl`. - `naming.url` now opens the meeting's speaker naming itself, for its owner, signed in. - A meeting a token sent shows that token's name, in the archive and on the meeting. ## 2026-10-18 ### Eight scopes: send, follow, search, ask, events, series and account - **Eight scopes,** fixed when a token is made: `recordings:read`, `recordings:write`, `recordings:query`, `series:read`, `series:write`, `series:query`, `account:read` and `account:write`. See [Tokens and scopes](/start/tokens/#the-scopes). [`GET /series/readable`](/reference/series/#listReadableSeries) moved to `series:read`, and every token that held `recordings:read` was given `series:read` too. - **[`GET /whoami`](/reference/token/#whoami):** who the token belongs to, its scopes, its cap and the upload limits. It answers every token. - **Limits per token:** each token has a request budget of its own for each class of route, over any 10 minutes (`429 RATE_LIMITED` with `Retry-After`), and it can have a daily minute cap (`429 TOKEN_DAILY_CAP`). See [Minutes, caps and limits](/start/limits/). - **Send a meeting and follow it** (`recordings:write`): [`POST /recordings`](/reference/recordings/#submitRecording), with an optional `Idempotency-Key`, then [`GET /recordings/{id}/status`](/reference/recordings/#getRecordingStatus), [skip naming](/reference/recordings/#skipRecordingNaming) and [retry](/reference/recordings/#retryRecording). See [The meeting lifecycle](/guides/lifecycle/). - **When a meeting happened:** a meeting can be sent with `recordedAt`, and the list and the bundle now carry it. - **Search and Ask:** [`GET /recordings/search`](/reference/recordings/#searchRecordings), and the free quotes and the Asks, of a meeting (`recordings:query`) and of a series (`series:query`). See [Search and Ask](/guides/search-and-ask/). - **The events feed,** [`GET /events`](/reference/events/#listEvents): what happened to your meetings, oldest first. It holds what happened from this release on, and nothing before it. See [Polling and the events feed](/guides/polling-and-events/). - **Formats:** the bundle as Markdown, with `?format=markdown`, and the OpenAPI document at `https://app.lobbe.ai/api/v1/openapi.json`. - **Series and account:** read, create and edit series and their vocabulary (`series:read`, `series:write`); read the account, change three of its settings, and read and change its vocabulary and its memory (`account:read`, `account:write`). See [Series](/guides/series/) and [Account, vocabulary and memory](/guides/account/). ## 2026-09-29 ### Series through a token - [`GET /series/readable`](/reference/series/#listReadableSeries): the series you own and the ones you joined. It needed `recordings:read` then; since 2026-10-18 it needs `series:read`. - [`GET /recordings/ready`](/reference/recordings/#listReadyRecordings) takes `seriesId`, for one series' finished meetings, and each item carries `series`. - The bundle reads a meeting of a series you joined, with `recording.access` set to `member`. - The list's cursor changed. The next page now starts right after the last meeting you got, so a meeting that leaves the list between two pages no longer makes the list skip the one after it. A cursor from before the change answers `400 VALIDATION`. ### Series meeting titles - List items and the bundle gain `displayTitle`, the title as the app shows it, and the bundle gains `subtitle`. In a series that titles its meetings by date, `displayTitle` is the series' name and the meeting's date, and the meeting's own title moves to `subtitle`. `title` doesn't change. ## 2026-09-19 ### The list of finished meetings - [`GET /recordings/ready`](/reference/recordings/#listReadyRecordings): your finished meetings, newest first, `limit` at a time (1–100, 20 by default), paged with `cursor`. Each item has its `id`, `title`, `kind`, `createdAt`, `durationSeconds`, `url` and `bundleUrl`. - It needs `recordings:read`, as the bundle does, so every token could read it from the start. ## 2026-09-13 ### Personal API tokens and the meeting bundle - Personal API tokens, made in the app: `lobbe_…`, shown once, up to 10 active at a time, expiring after 30, 90 or 365 days or never, and revocable at any time. - [`GET /recordings/{id}/bundle`](/reference/recordings/#getRecordingBundle): one finished meeting, with its details, its transcript with the speakers' names, its latest Lobbe and its stats. A meeting that isn't finished answers `409 NOT_READY`, with `details.status`. - This was the only route a token could call. :::note Sending meetings, creating and editing series, changing settings, search and Ask through a token are being switched on gradually. If the form in Connections doesn't offer a scope, it isn't available to your account yet. ::: --- # Versioning > What can change within v1 of the Lobbe API, and what would make a v2. ## Version 1 The API is version 1. Every path starts with `/api/v1`, and every JSON answer a route in the [reference](/reference/) gives to a successful request carries `schemaVersion: 1`. ## Within v1, changes only add A change within v1 adds something, and takes nothing away: - new routes; - new optional fields in a request; - new fields in an answer; - new values in a field whose description says it can have more, such as `owner.type` in [`GET /whoami`](/reference/token/#whoami). Nothing is removed or renamed, and `schemaVersion` stays `1`. So your client must ignore what it doesn't know. A field it doesn't recognize can appear anywhere in an answer: skip it, and never fail on it. [Read it leniently](/guides/pulling-meetings/#read-it-leniently) shows this for the bundle. ## A breaking change is a new version A change that does more than add ships only as a new version of the API, at `/api/v2`. ## Where changes are announced Changes are announced in the [changelog](/reference/changelog/).