API Reference¶
Speakr provides a comprehensive REST API (v1) for automation tools, dashboard widgets, and custom integrations. This reference documents all available endpoints.
Interactive Documentation
Access the interactive Swagger UI documentation at /api/v1/docs on your Speakr instance. You can test endpoints directly from your browser.
Base URL¶
All API v1 endpoints are prefixed with /api/v1:
Authentication¶
All endpoints require authentication, except the API description and the interactive API docs. See API Tokens for details on creating and managing tokens.
A header is the preferred way to send a token. The ?token= query parameter is deprecated and is accepted only on GET requests, and only with full-access tokens. A POST, PUT, PATCH or DELETE request that carries the token only in ?token= gets 401 with the code query_token_read_only. Every response to a request that uses ?token= carries the header Deprecation: true. A URL that contains a token can be recorded in proxy logs and browser history, so a header is the safer choice for every client.
Scopes¶
A token has full access or a set of scopes. Each endpoint below lists the scope it needs; /api/v1/openapi.json gives the same as x-required-scopes per operation.
| Scope | Allows |
|---|---|
read | Every GET under /api/v1 except webhook management: recordings, transcripts, summaries, notes, events, speakers, tags, folders, statistics, audio download |
write | Editing recordings, notes and summaries; adding and removing tags; creating and editing tags, folders and speakers; assigning speakers |
upload | POST /recordings/upload and the ASR Voice Recorder upload |
process | Requests that use model or GPU time: transcribe, summarize, regenerate title, identify speakers, chat |
share | Creating, listing and revoking shares |
delete | Deleting recordings, audio, tags, folders and speakers |
webhooks | Everything under /api/v1/webhooks |
account | Account settings under /api/v1/settings |
A token with no scopes is a full-access token and is reported as ["full"]. Every token created before scopes existed is a full-access token. Scoped tokens work only in a header and only on API v1 routes. A scoped token without a needed scope gets 403 before anything changes:
{
"error": "This token does not have the 'write' scope",
"code": "insufficient_scope",
"required_scopes": ["write"],
"token_scopes": ["read"]
}
with the header WWW-Authenticate: Bearer error="insufficient_scope", scope="write".
Current Token¶
Scope: none (any valid token)
Returns the token used for the request. A request signed in through the web interface gets 404 with "code": "not_found".
{
"id": 14,
"name": "mailr",
"scopes": ["read", "write", "upload"],
"created_at": "2026-10-02T09:00:00.000000Z",
"expires_at": "2027-10-02T09:00:00.000000Z",
"last_used_at": "2026-10-02T09:05:11.000000Z",
"via": "header"
}
Capabilities¶
Scope: none (any valid token or a signed-in session)
Lists the features this instance supports. Read features here, not from the version string; a feature missing from features is not supported.
{
"speakr_version": "v0.10.11-alpha",
"api_version": "1.1",
"features": {"token_scopes": true, "changes_feed": true, "etags": true},
"models_local": false
}
models_local is true only when the administrator sets MODELS_ARE_LOCAL=true, which states that the text, chat and embedding models all run on machines the administrator controls.
OpenAPI Specification¶
| Endpoint | Description |
|---|---|
GET /api/v1/docs | Interactive Swagger UI |
GET /api/v1/openapi.json | OpenAPI 3.0 specification |
Both addresses are available without signing in, by design. They describe the endpoints only and contain no user data or credentials.
Every operation in the specification has an operationId, which is required to add Speakr to Open WebUI as an OpenAPI tool server. When the specification is fetched with a scoped token, it lists only the operations that token may call, so a tool server built from it offers only those tools. A full token or a signed-in browser session receives every operation.
Interactive API documentation with Swagger UI at /api/v1/docs
Stats¶
Dashboard-compatible statistics endpoint, designed for integration with homepage widgets like gethomepage.dev.
Get Statistics¶
Scope: read
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
scope | string | user | user for personal stats, all for global (admin only) |
Response:
{
"recordings": {
"total": 150,
"completed": 120,
"processing": 5,
"pending": 20,
"failed": 5
},
"storage": {
"used_bytes": 5368709120,
"used_human": "5.0 GB"
},
"queue": {
"jobs_queued": 3,
"jobs_processing": 1
},
"tokens": {
"used_this_month": 450000,
"budget": 1000000,
"percentage": 45.0
},
"transcription": {
"used_this_month_seconds": 3600,
"used_this_month_minutes": 60,
"budget_seconds": 36000,
"budget_minutes": 600,
"percentage": 10.0,
"estimated_cost": 0.36
},
"activity": {
"recordings_today": 3,
"last_transcription": "2024-01-15T14:30:00Z"
}
}
gethomepage.dev Widget Configuration
- Speakr:
widget:
type: customapi
url: https://speakr.example.com/api/v1/stats
headers:
Authorization: Bearer YOUR_TOKEN
mappings:
- field: recordings.completed
label: Completed
- field: storage.used_human
label: Storage
- field: tokens.percentage
label: Token Usage
format: percent
- field: transcription.percentage
label: Transcription
format: percent
- field: activity.recordings_today
label: Today
Current User¶
Get Current User¶
Scope: read
Returns the authenticated user's profile, preferences, and group memberships. Useful for companion apps and automation flows that need to display the current user's identity.
Response:
{
"id": 42,
"username": "alice",
"email": "alice@example.com",
"name": "Alice Johnson",
"job_title": "Product Manager",
"company": "Acme Inc",
"is_admin": false,
"email_verified": true,
"sso_provider": null,
"can_share_publicly": true,
"preferences": {
"ui_language": "en",
"transcription_language": "en",
"output_language": "en",
"extract_events": true,
"auto_speaker_labelling": true,
"auto_speaker_labelling_threshold": 0.75,
"auto_summarization": true,
"show_timestamps_simple_view": false,
"editor_autosave": true,
"auto_translation": false,
"diarize": true
},
"group_memberships": [
{
"group_id": 3,
"group_name": "Engineering",
"role": "admin",
"joined_at": "2024-01-10T08:00:00Z"
}
],
"storage": {
"used_bytes": 244862976,
"quota_bytes": 314572800,
"available_bytes": 69709824
}
}
storage.quota_bytes and storage.available_bytes are null when the user has no storage quota.
Recordings¶
Upload Recording¶
Scope: upload
Upload a recording as multipart form-data and immediately queue transcription.
Form Fields:
| Field | Type | Required | Description |
|---|---|---|---|
file | file | yes | Audio or video file |
title | string | no | Title. Without it, the title comes from your title settings once the transcript exists. |
participants | string | no | Comma-separated names, at most 500 characters |
meeting_date | string | no | ISO 8601. Takes precedence over every other date source. |
file_last_modified | string | no | The file's last-modified time in milliseconds since the epoch. Used when meeting_date is absent and no date is read from the file name. |
notes | string | no | Notes |
folder_id | integer | no | Folder (your own or a group folder you belong to) |
tag_ids[0], tag_ids[1], ... | integer | no | Tags, in order (your own or group tags you belong to) |
tag_id | integer | no | Single tag (legacy) |
external_refs | string | no | JSON array of external references without id |
idempotency_key | string | no | 1 to 100 characters; see below |
strict | boolean | no | true: a tag or folder you cannot use is an error (400). Default false: it is dropped and reported in ignored. |
language | string | no | Language hint (ISO 639-1) |
min_speakers, max_speakers | integer | no | Speaker count limits |
hotwords, initial_prompt | string | no | Transcription hints |
transcription_model | string | no | One of the models the administrator allows |
prompt_variables | string | no | JSON object of {{name}} values for the summary prompt |
keep_audio_only | boolean | no | If true, the server discards the video stream and stores only the extracted audio. Lets you upload videos larger than max_file_size_mb, up to max_audio_only_video_size_mb, as long as the extracted audio fits the regular limit. When VIDEO_RETENTION is off at the server, this is implicit for video uploads. |
The meeting date is chosen in this order: meeting_date, a date in the file name (when you turned that on in your settings), file_last_modified, the date stored in the file, the upload time.
Idempotent uploads. Send an idempotency_key (for example a message id and attachment number) when a client may retry. A repeat with the same key by the same user within 24 hours returns the recording the first call created, with 200 and "idempotent_replay": true; the file is not stored again and no second transcription is queued. Keys are per user.
Response:
// 202 Accepted
{
"id": 123,
"title": "Weekly sync",
"status": "PENDING",
"created_at": "2024-01-15T10:00:00Z",
"meeting_date": "2024-01-15T09:00:00Z",
"participants": "Dana, Omar",
"file_size": 15728640,
"original_filename": "meeting.mp3",
"mime_type": "audio/mpeg",
"notes": "Quick test upload",
"external_refs": [{"id": 77, "system": "mailr", "kind": "event", "ref": "5f1c@example.com", "url": null, "label": null, "created_at": "2024-01-15T10:00:00.000000Z"}],
"ignored": {"tag_ids": [], "folder_id": null}
}
ignored lists the tags and folder the upload dropped because you cannot use them.
An upload that would exceed the user's storage quota is refused with 507 Insufficient Storage:
{
"error": "This upload would exceed your storage quota: 233.5 MB of 300.0 MB used, and the file is 80.0 MB.",
"code": "storage_quota_exceeded",
"used_bytes": 244862976,
"quota_bytes": 314572800,
"file_bytes": 83886080,
"available_bytes": 69709824
}
GET /api/v1/users/me includes the same figures under storage, and /api/v1/capabilities lists storage_quota.
A file that cannot be ingested is refused with 400. No recording is created and the file is not kept:
| Code | Meaning |
|---|---|
empty_file | The file part has no content. |
audio_decode_failed | The file cannot be decoded as audio or video, for example a text file with an .mp3 extension or a damaged file. |
{
"error": "The uploaded file could not be read as audio. It may be corrupt or in an unsupported format.",
"code": "audio_decode_failed"
}
A conversion that runs longer than FFMPEG_TIMEOUT_SECONDS is a server error: 500 with the code audio_conversion_timeout.
Example:
curl -X POST \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "file=@/path/to/audio.mp3" \
-F "title=Weekly sync" \
-F "participants=Dana, Omar" \
-F 'external_refs=[{"system":"mailr","kind":"event","ref":"5f1c@example.com"}]' \
-F "idempotency_key=msg-4411:part-2" \
https://speakr.example.com/api/v1/recordings/upload
ASR Voice Recorder Upload¶
Scope: upload
This adapter accepts the multipart webhook format sent by the Android ASR Voice Recorder app and queues the completed recording through Speakr's normal upload and transcription pipeline. Unlike other API endpoints, authentication comes from the required multipart secret field because the recorder cannot set a custom Authorization header.
Form Fields:
| Field | Type | Required | Description |
|---|---|---|---|
file | file | for uploads | Completed audio recording; omitted by ASR's connection test |
secret | string | yes | A personal Speakr API token |
file_name | string | no | Original filename override; sanitized by Speakr |
date | integer | no | Recording time as Unix epoch seconds or milliseconds |
duration | number | no | Accepted for compatibility but ignored; Speakr measures duration from the media |
note | string | no | Stored as the recording's notes |
A session cookie or normal API Authorization header does not replace secret on this endpoint. Create a dedicated, expiring token for the recorder because Speakr API tokens currently provide full access to their owner's account. The endpoint limits each source IP by both request count and total declared upload size; HTTP 429 means ASR should retry later. Multi-worker deployments using Flask-Limiter's in-memory storage should also enforce an aggregate upload rate at the reverse proxy.
When saving the webhook destination, ASR first sends an authenticated connection test without a file or recording metadata. Speakr returns HTTP 200 with {"status":"ok","connection_test":true} and does not create a recording. Completed deliveries still require the file field.
Response:
// 200 OK
{
"id": 123,
"title": "Recording - interview.m4a",
"status": "PENDING",
"original_filename": "interview.m4a",
"notes": "Interview recorded with an external microphone",
"idempotent_replay": false
}
An empty or undecodable file is refused with 400 and the codes empty_file or audio_decode_failed, as on Upload Recording.
If ASR retries the same completed file with matching filename, note, and recording date for the same user, Speakr returns the existing recording with idempotent_replay: true instead of queueing another transcription. Identical audio submitted with different metadata remains a distinct recording. Simultaneous first deliveries are not a strict exactly-once guarantee.
ASR Voice Recorder configuration:
- Webhook URL:
https://speakr.example.com/api/v1/integrations/asr-voice-recorder/upload - Secret: the dedicated Speakr API token
- Method/body: ASR supplies the multipart POST automatically
- Expected success status: HTTP 200
A manual protocol check can be made with placeholder credentials:
curl -X POST \
-F "secret=YOUR_DEDICATED_TOKEN" \
-F "file=@recording.m4a" \
-F "file_name=recording.m4a" \
-F "note=Mobile recorder upload" \
https://speakr.example.com/api/v1/integrations/asr-voice-recorder/upload
List Recordings¶
Scope: read
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number |
per_page | integer | 25 | Items per page (max: 100) |
status | string | all | Filter: all, pending, processing, completed, failed |
sort_by | string | created_at | Sort field: created_at, meeting_date, title, file_size, status, updated_at |
sort_order | string | desc | Sort order: asc, desc |
date_from | string | - | Filter from date (ISO format) |
date_to | string | - | Filter to date (ISO format); a date alone includes that whole day |
date_field | string | created_at | What date_from and date_to filter: created_at or meeting_date |
updated_since | string | - | Only recordings changed after this time (ISO 8601). Deletions are not listed; use the changes feed for those. |
external_system, external_ref, external_kind | string | - | Only recordings that carry this external reference of yours (external_kind optional) |
scope | string | own | own, shared (recordings other users shared with you) or all |
owner_id | integer | - | Only recordings of this owner |
tag_id | integer | - | Filter by tag ID |
q | string | - | Search query (title, participants) |
inbox | boolean | - | Filter by inbox status |
starred | boolean | - | Filter by starred status |
archived | boolean | - | Filter by archive status. Omitted returns archived and unarchived recordings, as before. |
folder_id | string | - | Filter by folder. Pass an integer folder ID to list recordings in that folder, or the literal none to list recordings not in any folder. Requires folders to be enabled. |
Response:
{
"recordings": [
{
"id": 123,
"title": "Team Meeting",
"status": "COMPLETED",
"created_at": "2024-01-15T10:00:00Z",
"completed_at": "2024-01-15T10:05:00Z",
"meeting_date": "2024-01-15T09:00:00Z",
"file_size": 15728640,
"original_filename": "meeting.mp3",
"participants": "Alice, Bob",
"is_inbox": false,
"is_highlighted": true,
"audio_available": true,
"audio_duration": 1830.5,
"has_transcription": true,
"has_summary": true,
"processing_time_seconds": 45.2,
"transcription_duration_seconds": 38.1,
"summarization_duration_seconds": 7.1,
"folder_id": 5,
"folder": {"id": 5, "name": "Client Calls"},
"deletion_exempt": false,
"error_message": null,
"keep_audio_only": false,
"tags": [
{"id": 1, "name": "Work", "color": "#3B82F6"}
],
"updated_at": "2024-01-15T10:05:00.000000Z"
}
],
"pagination": {
"page": 1,
"per_page": 25,
"total": 150,
"total_pages": 6,
"has_next": true,
"has_prev": false
}
}
Shared recordings. With scope=shared or scope=all the list includes completed recordings other users shared with you (shared directly, through a group tag or through a group folder), as the web app's shared list does. Every item says whose it is:
{
"is_shared": true,
"owner": {"id": 3, "username": "evan", "name": "Evan Ross"},
"share": {"id": 51, "can_edit": false, "can_reshare": false, "source": "group_tag",
"shared_at": "2026-09-20T10:00:00.000000Z"}
}
Your own recordings have "is_shared": false, "owner": null, "share": null. owner.username and owner.name are null unless the administrator sets SHOW_USERNAMES_IN_UI=true. For a shared recording, is_inbox, is_highlighted and is_archived are your own values and tags are the tags you can see. With internal sharing turned off, shared is empty and all is the same as own.
updated_at is the time of the last change a client can see: title, participants, notes, summary, transcript, status, dates, folder, flags, tags, events or sharing. It is present on every recording in API v1 and starts at created_at.
External References¶
A reference links a recording to an item in another system, such as a calendar event or a conversation in a mail client. References are private: you see only the ones you added, and a user a recording is shared with sees only their own. Every recording in the API carries your references in external_refs.
{
"id": 77,
"system": "mailr",
"kind": "event",
"ref": "5f1c@example.com@2026-10-06T15:00:00Z",
"url": "https://mail.example.com/calendar/event/5f1c",
"label": "Weekly sync (6 Oct)",
"created_at": "2026-10-06T16:02:11.000000Z"
}
system and kind: 1 to 40 characters of a-z, 0-9, _, ., -. ref: 1 to 500 printable characters. url: optional, http:// or https://, at most 1000 characters. label: optional, at most 200 characters. At most 50 references per user per recording (409 conflict).
Scope: read
Scope: write
Adds one reference: 201 with the new reference, or 200 with the existing one when you already added the same system, kind and ref.
Scope: write
Replaces your references of that system with {"external_refs": [...]} and returns the new list. References of other systems stay.
Scope: write
204. The writes need edit access to the recording, and a reference change counts as a recording change (updated_at, fields_changed: ["external_refs"]).
To find recordings by reference, filter the list: GET /api/v1/recordings?external_system=mailr&external_ref=<ref>, optionally with &external_kind=event.
Changes Feed¶
Scope: read
Every create, edit and delete of your recordings since a cursor, each once and in its latest state. This is the way to keep a copy of your recordings in sync without listing the whole collection.
| Parameter | Type | Default | Description |
|---|---|---|---|
cursor | string | - | next_cursor from the previous answer. Without it, the answer lists every recording (a full pass) and no older deletions. |
limit | integer | 100 | 1 to 500 |
scope | string | own | own, shared or all. With shared or all, a share that is removed arrives as a delete with reason access_revoked. |
Response:
{
"changes": [
{"type": "upsert", "recording": {"id": 412, "title": "Weekly sync", "updated_at": "2026-10-02T14:03:22.123456Z"}},
{"type": "delete", "id": 398, "deleted_at": "2026-10-02T14:05:00.000000Z", "reason": "deleted"}
],
"next_cursor": "eyJ1IjoiMjAyNi0xMC0wMlQxNDowNTowMC4wMDAwMDBaIiwiayI6MSwiaSI6Mzk4fQ",
"has_more": false
}
recordingin an upsert has the same fields as one item of List Recordings.reasonon a delete isdeleted(by a user, the API or a merge),retention(auto-deletion) oraccess_revoked(a recording shared with you is no longer shared).- Changes from the last two seconds wait for the next call, so a change saved late is never skipped.
- The cursor is opaque. Store
next_cursorafter every answer, including an answer with no changes. 400with"code": "invalid_parameter": the cursor cannot be read.410with"code": "cursor_expired": the cursor is older than the deletions Speakr still keeps (RECORDING_TOMBSTONE_DAYS, default 90). Start again without a cursor.
A sync loop:
cursor = stored cursor, or none
repeat:
answer = GET /api/v1/recordings/changes?cursor=<cursor>
if status is 410: drop the local copy's sync state, cursor = none, continue
apply each change: upsert replaces the recording, delete removes it
cursor = answer.next_cursor; store it
if not answer.has_more: wait (or wait for a webhook), then repeat
Conditional Requests¶
These requests send a weak ETag header:
| Request | Answer depends on |
|---|---|
GET /api/v1/recordings/{id} | the recording, its tags and folder, and the parameters |
GET /api/v1/recordings/{id}/transcript | the transcript, the format and, for format=text, your transcript template |
GET /api/v1/recordings/{id}/summary | the summary |
GET /api/v1/recordings/{id}/notes | the notes you can see |
GET /api/v1/recordings/{id}/events | the extracted events |
GET /api/v1/recordings/{id}/speakers | the speakers and the voice suggestions |
Send the value back in If-None-Match. When nothing in the answer changed, the answer is 304 Not Modified with no body. Treat the value as opaque. The access check comes first, so a caller without access gets 404, never 304. The answers carry Cache-Control: private, no-cache.
curl -i -H "Authorization: Bearer $TOKEN" -H 'If-None-Match: W/"t412-1790950000000000-3f2a9c0d1b7e4a55"' \
https://speakr.example.com/api/v1/recordings/412/transcript
Get Recording Details¶
Scope: read
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
format | string | full | full or minimal (excludes large text fields) |
include | string | transcription,summary,notes | Comma-separated fields to include |
Response:
{
"id": 123,
"title": "Team Meeting",
"status": "COMPLETED",
"participants": "Alice, Bob",
"created_at": "2024-01-15T10:00:00Z",
"meeting_date": "2024-01-15T09:00:00Z",
"completed_at": "2024-01-15T10:05:00Z",
"file_size": 15728640,
"original_filename": "meeting.mp3",
"mime_type": "audio/mpeg",
"is_inbox": false,
"is_highlighted": true,
"audio_available": true,
"audio_duration": 1830.5,
"processing_time_seconds": 45.2,
"transcription_duration_seconds": 38.1,
"summarization_duration_seconds": 7.1,
"folder_id": 5,
"folder": {"id": 5, "name": "Client Calls"},
"deletion_exempt": false,
"events": [
{
"id": 1,
"title": "Follow-up Meeting",
"start_datetime": "2024-01-22T10:00:00Z",
"end_datetime": "2024-01-22T11:00:00Z",
"description": "Discuss project progress",
"location": "Conference Room A"
}
],
"error_message": null,
"duplicate_info": null,
"keep_audio_only": false,
"transcription": "Alice: Hello everyone...",
"summary": "## Meeting Summary\n- Key point 1...",
"notes": "Personal notes..."
}
Field notes
audio_duration is in seconds. The *_duration_seconds fields report how long each processing stage took. events is only populated when calendar-event extraction is enabled. error_message is non-null only when status is FAILED. folder / folder_id are populated when folders are enabled and the recording is assigned to one.
Transcript Formatting
The transcription field is automatically formatted using your default transcript template. Configure templates in Account Settings → Transcript Templates.
Get Transcript¶
Scope: read
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
format | string | json | Output format: json, text, srt, vtt |
start, end | number | - | JSON only: segments that overlap this window, in seconds |
max_segments | integer | - | JSON only: at most this many segments; next_start gives the start time of the next one |
{
"format": "json",
"kind": "segments",
"duration": 1834.2,
"segments": [
{
"index": 0,
"speaker": "Alice",
"speaker_label": "SPEAKER_00",
"speaker_id": 12,
"text": "Hello everyone",
"sentence": "Hello everyone",
"start_time": 0.0,
"end_time": 2.5
}
]
}
Every segment has every key above, whatever format the transcript was saved in; clients can rely on this shape. text and sentence are the same (sentence is kept for older clients). Times are seconds or null. speaker_label is the diarization label (also after the speaker was named), or null when it is not known. speaker_id is the id of the saved speaker the segment is linked to, else null; for a linked segment, speaker is that speaker's current name. A transcript without speaker segments returns "kind": "plain", an empty segments list and the text in raw.
Uses your default transcript template:
Get Summary¶
Scope: read
Response:
Get Notes¶
Scope: read
Notes are per user. The owner gets the recording's notes. A user the recording is shared with gets only their own personal notes, never the owner's; the same holds for notes in the recording details and in search.
Response:
Get Processing Status¶
Scope: read
Response:
{
"id": 123,
"status": "PROCESSING",
"queue_position": 2,
"error_message": null,
"completed_at": null
}
Status Values:
| Status | Description |
|---|---|
PENDING | Waiting in queue |
PROCESSING | Transcription in progress |
SUMMARIZING | Summary generation in progress |
COMPLETED | Processing finished successfully |
FAILED | Processing failed (check error_message) |
Update Recording¶
Scope: write
Request Body:
{
"title": "Updated Title",
"participants": "Alice, Bob, Charlie",
"notes": "Updated notes...",
"summary": "Updated summary...",
"meeting_date": "2024-01-15T09:00:00Z",
"is_inbox": false,
"is_highlighted": true,
"is_archived": false,
"folder_id": 5
}
All fields are optional. Set folder_id to an integer to move the recording into that folder (you must have access to the target folder), or to null to remove it from any folder. folder_id requires folders to be enabled.
Replace Notes¶
Scope: write
The owner replaces the recording's notes. A user the recording is shared with, even with view access only, replaces their own personal notes; the owner's notes do not change. notes in PATCH /api/v1/recordings/{id} follows the same rule.
Request Body:
Replace Summary¶
Scope: write
Request Body:
Delete Audio, Keep Transcript¶
Scope: delete
Deletes the recording's media file (the video, for a recording with retained video) and keeps the transcript, summary and notes. Requires the same permission as deleting the recording. Returns 409 when the audio is already removed or the recording is still processing.
Response:
Delete Recording¶
Scope: delete
Response:
Share Links¶
Create or Reuse a Share Link¶
Scope: share
Only the recording's owner can create a link. Without force_new, the newest link of the recording is reused.
{"share_summary": true, "share_notes": false, "force_new": false, "update_existing": false, "expires_in_days": 30}
| Field | Default | Meaning |
|---|---|---|
share_summary | true | Show the summary on the shared page |
share_notes | false | Show the owner's notes. Off unless asked (the web dialog defaults to on). |
force_new | false | Create a new link even when one exists |
update_existing | false | Give a reused link the flags of this request. Without it a reused link keeps its flags, and flags_differ says when they differ from the request. |
expires_in_days | none | 1 to 3650; after that the link answers 404 |
201 for a new link, 200 for a reused one:
{
"share_url": "https://speakr.example.com/share/AbC123",
"existing": true,
"flags_differ": false,
"share": {"id": 31, "share_summary": true, "share_notes": false,
"created_at": "2026-10-02T09:00:00.000000Z", "expires_at": null}
}
403 with code feature_disabled (public sharing is off), not_permitted (your account may not create public links) or https_required (the link would not be an HTTPS link: when APP_BASE_URL is set, its scheme decides and links use that address; otherwise the request must arrive over HTTPS, and behind a reverse proxy Speakr reads X-Forwarded-Proto, see TRUSTED_PROXY_HOPS and TRUSTED_PROXIES).
List Share Links¶
Scope: share
Your links of the recording, newest first, each with its share_url.
Revoke a Share Link¶
Scope: share
204. The link stops working at once.
Search¶
Search Recordings¶
Scope: read
Finds words in the titles, participants, notes, summaries and transcripts of your recordings, with the time and speaker of each transcript hit. Keyword search works on every installation; semantic search needs Inquire mode.
| Parameter | Type | Default | Description |
|---|---|---|---|
q | string | required | 2 to 500 characters. Words are matched separately; "a quoted phrase" stays together. Every word must appear in the recording. % and _ are literal. |
mode | string | keyword | keyword, semantic, or auto (semantic when available, else keyword) |
fields | string | all | Comma list of title, participants, notes, summary, transcript (keyword mode) |
scope | string | own | own, shared or all (see List Recordings) |
recording_ids | string | - | Comma list of recording ids |
tag_id, folder_id | - | - | As on List Recordings |
speaker | string | - | Transcript hits of this speaker only (exact name, any case) |
date_from, date_to | string | - | ISO dates or date-times; a date alone includes that whole day |
date_field | string | meeting_date | meeting_date (the creation time where a recording has none) or created_at |
limit | integer | 20 | 1 to 50 hits |
page | integer | 1 | Keyword mode |
Response:
{
"query": "budget freeze",
"mode_used": "keyword",
"results": [
{
"recording_id": 412,
"title": "Weekly sync",
"meeting_date": "2026-09-29T15:00:00Z",
"field": "transcript",
"segment_index": 87,
"start_time": 754.2,
"end_time": 761.0,
"speaker": "Dana",
"text": "So the budget freeze applies to travel only.",
"match_spans": [[7, 13], [14, 20]],
"score": 0.7
}
],
"page": 1,
"has_more": false
}
- Keyword hits: the title and participants as a whole, notes and summaries per line, transcripts per segment.
textis at most 400 characters around the first match;match_spansare character offsets intotext. A transcript without speaker segments gives hits withstart_timeandsegment_indexset tonull. scoreis the share of the query words in the hit times a weight per field (title 1.0, summary 0.9, notes and participants 0.8, transcript 0.7). Ties go to the newer recording.- Keyword mode reads at most the 200 newest matching recordings per request.
- Semantic hits come from the Inquire index:
fieldistranscript,segment_indexisnull,scoreis the similarity. The query text is sent to the embedding model of the instance;GET /api/v1/capabilitiesreportsfeatures.search.semanticandmodels_local. mode=semanticon an instance without Inquire mode:409with"code": "semantic_unavailable".- Limit: 30 requests per minute per token (
API_SEARCH_RATE_LIMIT).
Tags¶
List Tags¶
Scope: read
Returns both personal tags and group tags you have access to. ?name= returns only the tag with that name (any case), so a client can find a tag by name.
Response:
{
"tags": [
{
"id": 1,
"name": "Work Meetings",
"color": "#3B82F6",
"is_group_tag": false,
"group_id": null,
"custom_prompt": "Focus on action items...",
"default_language": "en",
"default_min_speakers": 2,
"default_max_speakers": 10,
"protect_from_deletion": false,
"can_edit": true
}
]
}
Create Tag¶
Scope: write
Request Body:
{
"name": "Interviews",
"color": "#10B981",
"custom_prompt": "Extract candidate qualifications...",
"default_language": "en",
"default_min_speakers": 2,
"default_max_speakers": 3,
"group_id": null
}
Update Tag¶
Scope: write
Request Body:
Delete Tag¶
Scope: delete
Add Tags to Recording¶
Scope: write
Request Body:
Adds the tags after the ones already on the recording. Needs edit access to the recording. A group tag can be applied by the recording's owner or a group admin.
Tags that share. A group tag with auto-share on apply (or share with group lead) shares a completed recording with the group, as in the web app: admins get edit access, members read access. A scoped token needs the share scope for such a tag; without it the request gets 403 insufficient_scope and nothing changes.
Set Recording Tags¶
Scope: write
Sets the recording's tags to exactly this list, in this order. The access and sharing rules of Add Tags apply. Tags of other users that you cannot see stay on the recording.
Response:
{
"tags": [{"id": 3, "name": "Followed up", "color": "#10B981"}, {"id": 9, "name": "Client", "color": "#3B82F6"}],
"added": [9],
"removed": [4]
}
An unknown tag, or one you cannot use, gives 400 with "code": "invalid_parameter" and the refused ids in tag_ids; nothing changes.
Remove Tag from Recording¶
Scope: write
Speakers¶
List Speakers¶
Scope: read
?email= returns the speaker with that address (any case).
Response:
{
"speakers": [
{
"id": 1,
"name": "John Doe",
"use_count": 45,
"last_used": "2024-01-15T14:30:00Z",
"confidence_score": 0.87,
"has_voice_profile": true,
"email": "john@example.com",
"aliases": ["Johnny"],
"updated_at": "2024-01-15T14:30:00.000000Z"
}
]
}
email and aliases are contact details you give your speakers. Only you see them: they are not part of shared recordings, public shares, exports or webhooks, and they are deleted with the speaker.
Create Speaker¶
Scope: write
Request Body:
email and aliases are optional.
Update Speaker¶
Scope: write
Changes the name, email or aliases; send only what changes. A new name appears in every recording the speaker is in (transcript, participants, search). email must be an address (stored in lower case); null or "" removes it. aliases: at most 20 names of at most 100 characters, trimmed, duplicates (any case) dropped; [] removes them. A name another of your speakers has gives 409.
Request Body:
Delete Speaker¶
Scope: delete
Get Recording Speakers¶
Scope: read
Returns the people in the recording with voice-based identification suggestions. Each entry is one person: the diarization label, the name given to it (null while unnamed), the saved speaker it is linked to, and email when you own both the recording and the speaker (null for anyone else).
Response:
{
"speakers": [
{
"label": "SPEAKER_00",
"identified_name": "John Doe",
"speaker_id": 1,
"email": "john@example.com",
"segment_count": 23
}
],
"suggestions": {
"SPEAKER_01": [
{"speaker_id": 2, "name": "Jane Smith", "similarity": 89.5}
]
}
}
Processing Operations¶
Queue Transcription¶
Scope: process
Request Body:
{
"language": "en",
"min_speakers": 1,
"max_speakers": 5,
"hotwords": "Speakr, WhisperX, diarization",
"initial_prompt": "Technical discussion about audio transcription.",
"transcription_model": "gpt-4o-transcribe-diarize"
}
All parameters are optional.
| Parameter | Type | Description |
|---|---|---|
language | string | Language hint (ISO 639-1). Empty string forces auto-detect. |
min_speakers | integer | Minimum speaker count for diarization. |
max_speakers | integer | Maximum speaker count for diarization. |
hotwords | string | Word-biasing hint. Connectors that accept biasing route this to their native parameter (WhisperX hotwords, Mistral context bias, OpenAI prompt). Connectors that ignore it drop it silently. |
initial_prompt | string | Free-text context hint. Same per-connector behavior as hotwords. |
transcription_model | string | Per-request model override. Validated against the admin-curated visible-models list; falls back to the configured default if absent or invalid. Use GET /api/v1/transcription to discover valid values. |
Response:
Queue Summarization¶
Scope: process
Request Body:
The custom prompt overrides the recording's tag prompts and user defaults.
Chat¶
Chat with Recording¶
Scope: process
Ask questions about a recording's content using AI.
Request Body:
{
"message": "What were the main action items discussed?",
"conversation_history": [
{"role": "user", "content": "Who attended?"},
{"role": "assistant", "content": "John and Jane attended..."}
]
}
Response:
{
"response": "The main action items were:\n1. Complete the report by Friday\n2. Schedule follow-up meeting...",
"sources": []
}
Sources. With "with_sources": true, the transcript goes to the model with segment markers and the answer cites segments with short quotes. Citations in response become [1], [2], and sources lists them:
{
"response": "The freeze covers travel only [1].",
"sources": [{"n": 1, "segment_index": 12, "start_time": 754.2, "end_time": 761.0,
"speaker": "Dana", "quote": "the budget freeze applies to travel only", "verified": true}]
}
verified is true when the quote appears in that segment (ignoring case and spacing). A citation of a segment that does not exist has segment_index: null and verified: false.
The notes in the chat context are the ones you can see: your own for your recordings, your personal notes for a recording shared with you. Chat sends the transcript to the chat model of the instance; GET /api/v1/capabilities reports models_local.
Inquire¶
Ask Across Recordings¶
Scope: process
Answers a question from the recordings you can read, with Inquire's search (and the Inquire agent when the administrator turned it on). Needs Inquire mode on the instance (403 feature_disabled otherwise).
{
"question": "What did we decide about the course redesign?",
"filters": {"recording_ids": [412, 398], "tag_ids": [], "folder_ids": [], "speakers": [], "date_from": "2026-09-01", "date_to": null},
"scope": "own",
"mode": "auto",
"stream": false,
"history": []
}
mode:rag,agent(403 feature_disabledwhen the agent is off) orauto(the agent when it is on).scope:own,sharedorall, as on List Recordings.stream: truereturns the server-sent events of the web app's Inquire (delta,status,agent_step,agent_summary,error).
Response (stream: false):
{
"answer": "The redesign moves the project to week 6 [1] and drops the midterm [2].",
"citations": [
{"n": 1, "recording_id": 412, "title": "Weekly sync", "start_time": 754, "quote": null,
"url": "/recordings/412?t=754"},
{"n": 2, "recording_id": 398, "title": "Budget review", "start_time": null, "quote": null,
"url": "/recordings/398"}
],
"mode_used": "agent",
"steps": 4,
"usage": null
}
Every citation names a recording you can read. The question and the matching transcript passages go to the instance's chat model.
Events¶
Get Calendar Events¶
Scope: read
Returns calendar events extracted from the recording. The events in Get Recording Details are the same objects.
Response:
{
"events": [
{
"id": 1,
"recording_id": 412,
"title": "Follow-up Meeting",
"start_datetime": "2024-01-22T10:00:00",
"end_datetime": "2024-01-22T11:00:00",
"floating": true,
"description": "Discuss project progress",
"location": "Conference Room A",
"attendees": [
{"name": "Dana Lee", "email": null},
{"name": "Priya Shah", "email": "priya@example.org"}
],
"reminder_minutes": 15,
"created_at": "2024-01-15T10:05:00"
}
]
}
floating: true:start_datetimeandend_datetimeare wall-clock times as said in the recording, without a time zone. Show them as they are, in the user's own zone.attendeesare objects: a name, an address, or both. A name the transcript gave without an address hasemail: null.- Before v0.10.11-alpha,
attendeesin the recording details were plain strings.
Download Events as ICS¶
Scope: read
Returns an ICS file containing all events from the recording. Times are floating (no zone). Attendees with an address are ATTENDEE entries; names without an address are listed at the end of the description. reminder_minutes becomes an alarm.
Audio¶
Download Audio¶
Scope: read
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
download | boolean | false | true to force download, false to stream |
Batch Operations¶
Batch Update Recordings¶
Scope: write
Request Body:
{
"recording_ids": [1, 2, 3],
"updates": {
"is_inbox": false,
"is_highlighted": true,
"add_tag_ids": [5],
"remove_tag_ids": [2],
"folder_id": 5
}
}
Supported updates fields: is_inbox, is_highlighted, add_tag_ids, remove_tag_ids, and folder_id (move all selected recordings into that folder, or null to remove them from their folders; you must have access to the target folder, and folders must be enabled). A 403 is returned if you lack access to the target folder, and 404 if it does not exist.
Response:
{
"success": true,
"updated": 3,
"failed": 0,
"results": [
{"id": 1, "success": true},
{"id": 2, "success": true},
{"id": 3, "success": true}
]
}
Batch Delete Recordings¶
Scope: delete
Request Body:
Batch Queue Transcriptions¶
Scope: process
Request Body:
Folders¶
Folders organize recordings into a one-to-many structure (a recording belongs to at most one folder).
Folders must be enabled
The folders feature is off by default. An administrator must enable it under System Settings → enable_folders. While disabled, GET /api/v1/folders returns an empty list and the create/update/delete endpoints return 403 {"error": "Folders feature is not enabled"}.
Folders can be personal or group-scoped. Personal folders are editable only by their owner. Group folders are visible to all members of the group but editable only by group admins. Each folder's to_dict() includes a can_edit flag indicating whether the calling user may modify it.
List Folders¶
Scope: read
Returns personal folders plus any group folders you have access to.
Response:
[
{
"id": 5,
"name": "Client Calls",
"color": "#10B981",
"group_id": null,
"is_group_folder": false,
"group_name": null,
"custom_prompt": "Focus on action items...",
"default_language": "en",
"default_min_speakers": 2,
"default_max_speakers": 5,
"default_hotwords": null,
"default_initial_prompt": null,
"default_transcription_model": null,
"protect_from_deletion": false,
"retention_days": null,
"auto_share_on_apply": true,
"share_with_group_lead": true,
"naming_template_id": null,
"naming_template_name": null,
"export_template_id": null,
"export_template_name": null,
"created_at": "2024-01-10T08:00:00Z",
"recording_count": 12,
"can_edit": true,
"user_role": null
}
]
Get Folder¶
Scope: read
Returns a single folder. You must own a personal folder, or be a member of the group for a group folder. The response is the folder object above (with can_edit).
Create Folder¶
Scope: write
Request Body:
{
"name": "Client Calls",
"color": "#10B981",
"group_id": null,
"custom_prompt": "Focus on action items...",
"default_language": "en",
"default_min_speakers": 2,
"default_max_speakers": 5,
"default_hotwords": "Acme, Speakr",
"default_initial_prompt": "Sales call.",
"default_transcription_model": "gpt-4o-transcribe-diarize",
"retention_days": null
}
Only name is required. Set group_id to create a group folder (you must be an admin of that group). Use retention_days: -1 to mark the folder as protected from auto-deletion (only effective when auto-deletion is enabled at the server). Returns the created folder with 201.
Update Folder¶
Accepts the same fields as create; all are optional. Only the folder owner (personal) or a group admin (group folder) may update. Returns the updated folder.
Delete Folder¶
Scope: delete
Recordings in the deleted folder are unassigned (their folder_id becomes null); they are not deleted. Only the folder owner (personal) or a group admin (group folder) may delete.
Response:
Assigning recordings to folders
To move a recording into or out of a folder, set folder_id on PATCH /api/v1/recordings/{id} or in the updates object of batch update. To list a folder's recordings, use GET /api/v1/recordings?folder_id={id} (or folder_id=none for unfiled recordings).
Transcription Connector¶
Discover Active Connector¶
Scope: read
Returns the active transcription connector, the optional fields it accepts, the admin-curated list of selectable models, and the configured default model. Use this to drive client UIs and to know which values are valid for the transcription_model override on /recordings/{id}/transcribe and /recordings/upload.
Response:
{
"connector": "openai_transcribe",
"capabilities": {
"diarization": true,
"speaker_count_control": false,
"hotwords": true,
"initial_prompt": true,
"timestamps": true,
"language_detection": true,
"chunking": false
},
"models": [
{"value": "gpt-4o-transcribe-diarize", "label": "GPT-4o Diarize"}
],
"default_model": "gpt-4o-transcribe-diarize"
}
connector may be null when the legacy transcription architecture is in use. The capabilities flags indicate which optional request fields (hotwords, initial_prompt, speaker-count controls) the active connector honors; connectors that do not support a field drop it silently.
Webhooks¶
Webhooks deliver event notifications to an external URL when things happen to your recordings. Each webhook is owned by the user who creates it. The number of webhooks per user is capped (default 10, configurable by the administrator via WEBHOOK_MAX_PER_USER).
Delivery security and reliability
Each delivery is signed twice with the webhook's secret: Speakr-Signature-V2 (t=<send time>,v1=<hex>, HMAC-SHA256 of "<t>." + raw body; verify this one when present) and Speakr-Signature (sha256=<hex> of the raw body). Deliveries also carry Speakr-Delivery-Id (a UUID for idempotency; answer 2xx to a repeat), Speakr-Event, Speakr-Attempt, and Speakr-Timestamp (not signed). recording.updated fires for every visible change, from the web app or the API. Failed deliveries are retried automatically; after a configurable number of consecutive failures (WEBHOOK_AUTOPAUSE_FAILURES, default 10) the webhook is auto-paused (auto_paused: true, enabled: false). Re-enabling it manually clears the auto-pause flag.
For administrator-level configuration and receiver setup, see the Webhooks admin guide.
Event Types¶
These are the event types a webhook can subscribe to:
| Event | Fired when |
|---|---|
recording.created | A recording is created |
recording.transcription.started | Transcription begins |
recording.transcription.completed | Transcription finishes successfully |
recording.transcription.failed | Transcription fails |
recording.summary.completed | Summary generation finishes |
recording.summary.failed | Summary generation fails |
recording.events.extracted | Calendar events are extracted |
recording.updated | A recording is updated |
recording.deleted | A recording is deleted |
recording.share.created | A public share link is created |
recording.share.revoked | A public share link is revoked |
webhook.test | A manual test delivery (see Test Webhook below) |
List Webhooks¶
Scope: webhooks
Response:
{
"webhooks": [
{
"id": 1,
"user_id": 42,
"name": "n8n pipeline",
"url": "https://hooks.example.com/speakr",
"allow_http": false,
"events": ["recording.transcription.completed", "recording.summary.completed"],
"enabled": true,
"auto_paused": false,
"consecutive_failures": 0,
"created_at": "2024-01-15T10:00:00Z",
"updated_at": "2024-01-15T10:00:00Z",
"last_delivery_at": "2024-01-16T09:30:00Z"
}
],
"event_types": [
"recording.created",
"recording.transcription.started",
"recording.transcription.completed",
"recording.transcription.failed",
"recording.summary.completed",
"recording.summary.failed",
"recording.events.extracted",
"recording.updated",
"recording.deleted",
"webhook.test"
],
"max_per_user": 10
}
The webhook's HMAC secret is never returned by this endpoint. It is shown only once, in the create and rotate-secret responses.
Create Webhook¶
Scope: webhooks
Request Body:
{
"name": "n8n pipeline",
"url": "https://hooks.example.com/speakr",
"events": ["recording.transcription.completed"],
"allow_http": false,
"enabled": true
}
name, url, and a non-empty events array are required; every event must be a known event type. allow_http (default false) must be true for http:// URLs. The URL is validated against an SSRF guard. Exceeding the per-user cap returns 409.
Response (201): the webhook object, including the secret field once:
{
"id": 1,
"user_id": 42,
"name": "n8n pipeline",
"url": "https://hooks.example.com/speakr",
"allow_http": false,
"events": ["recording.transcription.completed"],
"enabled": true,
"auto_paused": false,
"consecutive_failures": 0,
"created_at": "2024-01-15T10:00:00Z",
"updated_at": "2024-01-15T10:00:00Z",
"last_delivery_at": null,
"secret": "xK9...copy-me-now..."
}
Get Webhook¶
Scope: webhooks
Returns the webhook object (without the secret).
Update Webhook¶
Scope: webhooks
Request Body (all fields optional):
{
"name": "Updated name",
"url": "https://hooks.example.com/v2",
"events": ["recording.transcription.completed", "recording.deleted"],
"allow_http": false,
"enabled": true
}
When provided, events must be a non-empty array of known event types. Setting enabled: true also clears the auto_paused flag. Returns the updated webhook.
Delete Webhook¶
Scope: webhooks
Returns 204 No Content.
Rotate Secret¶
Scope: webhooks
Generates a fresh HMAC secret and returns the webhook object with the new secret included once. Existing deliveries already signed with the old secret are not re-signed.
Test Webhook¶
Scope: webhooks
Enqueues a synthetic webhook.test delivery against this single webhook so you can verify reachability before subscribing to production events. The webhook must be enabled (otherwise 409).
Response (202): the created delivery object (see below).
List Deliveries¶
Scope: webhooks
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 50 | Max deliveries to return (1–200) |
Response:
{
"deliveries": [
{
"id": 1001,
"webhook_id": 1,
"event_id": "550e8400-e29b-41d4-a716-446655440000",
"event_type": "recording.transcription.completed",
"attempt_count": 1,
"status": "success",
"response_status": 200,
"response_body_preview": "{\"ok\":true}",
"error_message": null,
"next_retry_at": null,
"created_at": "2024-01-16T09:30:00Z",
"delivered_at": "2024-01-16T09:30:01Z"
}
],
"limit": 50
}
Delivery status is one of pending, success, failed (retryable), or permanent_failure (retries exhausted or non-retryable).
Get Delivery¶
Scope: webhooks
Returns a single delivery. This response additionally includes the full serialized payload (the exact JSON body that was/will be POSTed) for debugging.
Replay Delivery¶
Scope: webhooks
Re-enqueues the delivery as a brand-new attempt with the same payload (with a fresh event_id and timestamp, plus a replayed_from reference to the original). Returns the new delivery object with 202.
Error Responses¶
All endpoints return consistent error responses:
error is written in the interface language of the account that owns the token (Account > Interface Language), and in English when none is set. code is a machine-readable value that is the same in every language, for example insufficient_scope, invalid_parameter or not_found. A client is built on code and the HTTP status, not on the text of error. Errors that pass on the text of an unexpected exception carry no code.
Common HTTP Status Codes:
| Code | Description |
|---|---|
200 | Success |
201 | Created |
400 | Bad Request - Invalid parameters |
401 | Unauthorized - Invalid or missing token |
403 | Forbidden - You can see the recording but this request needs more (edit access, ownership, a target folder), or the token lacks a scope (insufficient_scope) |
404 | Not Found - Resource doesn't exist. A recording you cannot see gets the same answer as an id that does not exist, in single and batch requests |
429 | Too Many Requests - Rate limit reached; wait Retry-After seconds |
500 | Internal Server Error |
Rate Limits¶
Requests made with an API token are held by two limits for each kind of request. Both are counted separately for each endpoint:
- Token limit. Each token has its own budget, so two integrations with their own tokens do not share it.
- Account limit. On each endpoint, all tokens of one account draw on one budget, so a further token adds no throughput there.
| Kind | Endpoints | Token limit | Account limit |
|---|---|---|---|
| Read | Endpoints that need only read | 120 requests/minute (API_TOKEN_RATE_LIMIT_READ) | 240 requests/minute (API_USER_RATE_LIMIT_READ) |
| Write | Endpoints that need write, upload, share, delete, webhooks or account | 30 requests/minute (API_TOKEN_RATE_LIMIT_WRITE) | 60 requests/minute (API_USER_RATE_LIMIT_WRITE) |
| Process | Endpoints that need process | 10 requests/minute (API_TOKEN_RATE_LIMIT_PROCESS) | 20 requests/minute (API_USER_RATE_LIMIT_PROCESS) |
An account limit that is not set is twice the token limit of the same kind, so a client with a single token is held by its token limit. Because both limits are per endpoint, a client that polls several endpoints, such as a mail client during a sync, is never refused by the account limit while each endpoint stays within its token limit. The limits apply to full-access and scoped tokens alike. A request that the token limit refuses does not count against the account limit.
A request over a limit gets 429 with a Retry-After header in seconds. The body names the limit that ran out:
{
"error": "Rate limit reached for your account: 240 per minute (read requests on this endpoint), shared by all your API tokens.",
"code": "account_rate_limit_exceeded",
"limit": "240 per 1 minute"
}
code is token_rate_limit_exceeded for the token limit and account_rate_limit_exceeded for the account limit. Responses also carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; while no limit is exceeded, these headers describe the token limit only. Base a client on the 429 body, not on the header values.
Requests from the web interface keep the per-address limits of the application (1000 per hour and 5000 per day). The ASR Voice Recorder upload carries its token in the form body, so neither limit applies to it; that endpoint keeps its own per-address limit.
Several worker processes. By default the counters are kept in the memory of each worker process. The standard image starts three gunicorn workers, so each limit, the login and registration limits included, is in effect up to about three times its configured value. With RATELIMIT_STORAGE_URI set to a Redis or Valkey server, the limits are exact. The image includes the Redis client. The server is run as a separate container, for example in the same Docker Compose file as Speakr:
with RATELIMIT_STORAGE_URI=redis://redis:6379/0 in .env. The counters need no persistence, so no volume is required. While the server is unreachable, counting falls back to the memory of each process and requests are served as before. A Memcached URI (memcached://) is also accepted, in an image with the pymemcache package added; Speakr does not start with a backend URI whose client is missing.
Integration Examples¶
Python SDK Pattern¶
import requests
class SpeakrAPI:
def __init__(self, base_url, token):
self.base_url = base_url.rstrip('/')
self.session = requests.Session()
self.session.headers['Authorization'] = f'Bearer {token}'
def get_stats(self):
return self.session.get(f'{self.base_url}/api/v1/stats').json()
def list_recordings(self, status='all', page=1):
return self.session.get(
f'{self.base_url}/api/v1/recordings',
params={'status': status, 'page': page}
).json()
def get_transcript(self, recording_id, format='text'):
return self.session.get(
f'{self.base_url}/api/v1/recordings/{recording_id}/transcript',
params={'format': format}
).json()
# Usage
api = SpeakrAPI('https://speakr.example.com', 'YOUR_TOKEN')
stats = api.get_stats()
print(f"Total recordings: {stats['recordings']['total']}")
n8n Workflow¶
- Use HTTP Request node
- Set Method to
GET - Set URL to
https://your-instance/api/v1/recordings - In Authentication, select Header Auth
- Add header:
Authorization=Bearer YOUR_TOKEN
Zapier Integration¶
Use the Webhooks by Zapier app with:
- Trigger: Custom webhook
- Action: GET request to
/api/v1/recordings - Headers:
Authorization: Bearer YOUR_TOKEN
Next: Learn about API Tokens for authentication setup.