Authentication
Create a key in the app under Settings, API keys. Keys look like cs_live_... and are shown once. Send one with every request:
Authorization: Bearer cs_live_xxxxxxxxxxxxxxxx
All endpoints live under https://your-domain/v1. A key acts for the workspace it was created in; usage is charged to that workspace's balance. Read-only keys can list and download but cannot create or delete jobs.
Errors and limits
Errors use normal HTTP status codes and one JSON shape:
{ "error": { "code": "insufficient_balance", "message": "Add credit to transcribe this file.", "details": { "needed_cents": 412 } } }
| Status | Meaning |
|---|---|
400 | Invalid input; details.field names the field |
401 | Missing or revoked key |
402 | Not enough credit, or the monthly cap would be exceeded |
404 | No such job in this workspace |
409 | Wrong state, for example the transcript is not ready yet |
415 | Unsupported file type |
429 | Rate limited: 120 requests per minute per key. Honor Retry-After |
POST/v1/jobs
Creates a job and returns a one-time upload URL. Include duration_sec if you know it for an accurate estimate. Send an Idempotency-Key header to make retries safe.
curl https://your-domain/v1/jobs \
-H "Authorization: Bearer $CS_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "board-meeting.m4a",
"size_bytes": 48211022,
"mime": "audio/mp4",
"duration_sec": 3121,
"speakers_expected": 4,
"vocabulary": ["Okonkwo", "Q3 roadmap"],
"callback_url": "https://example.com/hooks/transcripts"
}'
{
"id": "job_8f2c...",
"status": "awaiting_upload",
"est_cost_cents": 521,
"upload_url": "https://your-domain/u/up_3b9e...",
"upload_method": "PUT",
"upload_expires_in": 3600
}
Other fields: project_id, language (default auto), style (clean or verbatim), notify_emails, notify_mode. To import from a public link instead of uploading, send url; the job goes straight to queued.
PUTupload_url
PUT the raw file bytes to the URL you got back. No auth header is needed; the URL itself is the credential and expires after an hour.
curl -X PUT --data-binary @board-meeting.m4a \
-H "Content-Type: audio/mp4" "$UPLOAD_URL"
GET/v1/jobs/{id}
Status moves awaiting_upload → queued → transcribing → ready (or failed). progress is 0 to 100. Prefer webhooks to polling; if you poll, every 10 to 30 seconds is plenty.
GET/v1/jobs/{id}/transcript
Returns the structured transcript once the job is ready:
{
"job_id": "job_8f2c...",
"version": 1,
"doc": {
"speakers": [{ "id": "S1", "name": "Speaker 1", "color": "#4f5bd5" }],
"turns": [{
"id": "t1", "speaker": "S1", "start": 0.42, "end": 6.1,
"tokens": [{ "w": "Thanks" }, { "w": "everyone" }, { "w": "Okonkwo", "u": 1, "alts": ["Okonko", "O'Konkwo"] }]
}]
},
"insights": { "summary": "...", "chapters": [{ "start": 0, "title": "Opening" }], "actions": ["..."], "keywords": ["..."] }
}
Tokens with "u": 1 are low-confidence words; alts holds the likely alternatives. You can save edits back with PUT /v1/jobs/{id}/transcript and { "doc": ..., "base_version": 1 }; a stale base_version returns 409 version_conflict.
GET/v1/jobs/{id}/export
Downloads a file. format is one of docx, txt, srt, vtt, json, html. Add timestamps=0 or speakers=0 to leave those out.
curl -o meeting.docx -H "Authorization: Bearer $CS_KEY" \
"https://your-domain/v1/jobs/job_8f2c.../export?format=docx"
Other endpoints
GET/v1/jobs | List jobs. Filters: status, project_id, q; paginate with before and limit (returns has_more and next_before) |
PATCH/v1/jobs/{id} | Rename, or move to another project |
POST/v1/jobs/{id}/cancel | Cancel a job that has not finished. Not charged |
POST/v1/jobs/{id}/retry | Retry a failed job |
DELETE/v1/jobs/{id} | Delete the job, its audio and every transcript version |
GET/v1/jobs/{id}/audio | Stream the original audio; supports Range |
GET/v1/estimate?duration_sec= | Price a file before creating a job |
Webhooks
Two ways to hear about finished jobs: a per-job callback_url, or workspace webhooks configured under Settings, Webhooks (with delivery logs and retries). Events are job.completed and job.failed:
{
"id": "evt_41d0...",
"type": "job.completed",
"created": "2026-10-22T18:04:11.000Z",
"data": { "id": "job_8f2c...", "name": "board-meeting.m4a", "status": "ready", "duration_sec": 3121, "cost_cents": 521 }
}
Respond with any 2xx within 10 seconds. Failed deliveries are retried with backoff for about a day.
Verify signatures
Every delivery carries a Scribberty-Signature header: t=<unix seconds>,v1=<hex>, where the hex is an HMAC-SHA256 of t + "." + raw body keyed with your webhook secret. Reject anything older than five minutes.
import crypto from 'node:crypto';
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}