01 / QUICKSTART
From local file to transcript
- Create a user API key from your PlainScribe profile and store it as
PLAINSCRIBE_API_KEY. - Keep the key in a trusted server or local agent process. Never embed it in browser or mobile application code.
- That key can use every endpoint currently exposed under API v1; PlainScribe does not require per-endpoint scopes.
- Create an upload using the exact local byte size.
- PUT the file bytes to the returned
upload.url. Above 6MB, use the returned TUS endpoint and headers verbatim with its 6MB chunk size. Signed TUS endpoints end in/resumable/sign. - Start the transcription with the returned upload ID.
- Poll
links.self; when complete, fetchlinks.result.
curl https://www.plainscribe.com/api/v1/uploads \
-H "Authorization: Bearer $PLAINSCRIBE_API_KEY" \
-H "Idempotency-Key: upload-interview-001" \
-H "Content-Type: application/json" \
-d '{
"file_name": "interview.mp3",
"size_bytes": 18432000,
"content_type": "audio/mpeg"
}'Upload the bytes to the returned URL, preserving the returned Content-Type header.
curl https://www.plainscribe.com/api/v1/transcriptions \
-H "Authorization: Bearer $PLAINSCRIBE_API_KEY" \
-H "Idempotency-Key: transcribe-interview-001" \
-H "Content-Type: application/json" \
-d '{
"source": {
"type": "upload",
"upload_id": "UPLOAD_ID"
},
"mode": "transcription"
}'Complete resumable example
This Node.js example creates a reservation, streams the file through the returned signed TUS configuration, starts the job, polls it, and prints the text result. It works for small files too.
// npm install tus-js-client@4.3.1
// PLAINSCRIBE_API_KEY=... node transcribe.mjs ./interview.mp3 audio/mpeg
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import path from 'node:path'
import { randomUUID } from 'node:crypto'
import * as tus from 'tus-js-client'
const API_BASE = 'https://www.plainscribe.com/api/v1'
const apiKey = process.env.PLAINSCRIBE_API_KEY
const [filePath, contentType = 'audio/mpeg'] = process.argv.slice(2)
if (!apiKey || !filePath) throw new Error('API key and file path are required')
async function api(route, options = {}) {
const response = await fetch(API_BASE + route, {
...options,
headers: {
Authorization: 'Bearer ' + apiKey,
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...options.headers,
},
})
if (!response.ok) throw new Error(response.status + ': ' + await response.text())
return response.json()
}
const file = await stat(filePath)
// Generate each key once. If a POST times out, retry it with the same key and unchanged body.
const uploadIdempotencyKey = randomUUID()
const reservation = await api('/uploads', {
method: 'POST',
headers: { 'Idempotency-Key': uploadIdempotencyKey },
body: JSON.stringify({
file_name: path.basename(filePath),
size_bytes: file.size,
content_type: contentType,
}),
})
const config = reservation.resumable_upload
const metadata = Object.fromEntries(
config.headers['Upload-Metadata'].split(',').map((entry) => {
const separator = entry.indexOf(' ')
return [
entry.slice(0, separator),
Buffer.from(entry.slice(separator + 1), 'base64').toString('utf8'),
]
}),
)
await new Promise((resolve, reject) => {
new tus.Upload(createReadStream(filePath), {
endpoint: config.endpoint,
headers: { 'x-signature': config.headers['x-signature'] },
metadata,
uploadSize: file.size,
chunkSize: config.chunk_size_bytes,
retryDelays: [0, 3000, 5000, 10000, 20000],
onError: reject,
onSuccess: resolve,
}).start()
})
const transcriptionIdempotencyKey = randomUUID()
let job = await api('/transcriptions', {
method: 'POST',
headers: { 'Idempotency-Key': transcriptionIdempotencyKey },
body: JSON.stringify({
source: { type: 'upload', upload_id: reservation.id },
mode: 'transcription',
}),
})
while (job.status === 'pending' || job.status === 'processing') {
await new Promise((resolve) => setTimeout(resolve, 5000))
job = await api('/transcriptions/' + job.id)
}
if (job.status !== 'completed') throw new Error(job.error?.message || 'Failed')
const result = await fetch(job.links.result + '?format=txt', {
headers: { Authorization: 'Bearer ' + apiKey },
})
if (!result.ok) throw new Error(result.status + ': ' + await result.text())
console.log(await result.text())API uploads accept AAC, AIF, AIFC, AIFF, AU, AVI, CAF, FLAC, M4A, M4V, MKV, MOV, MP3, MP4, MPEG, MPG, OGA, OGG, OPUS, TS, WAV, WAVE, WEBA, and WEBM files up to 1GB.
02 / YOUTUBE
A URL uses the same job model
YouTube does not need an upload reservation. Submit the URL directly, receive the same transcription object, and use the same polling and result endpoints as file-based jobs.
curl https://www.plainscribe.com/api/v1/transcriptions \
-H "Authorization: Bearer $PLAINSCRIBE_API_KEY" \
-H "Idempotency-Key: youtube-video-001" \
-H "Content-Type: application/json" \
-d '{
"source": {
"type": "youtube",
"url": "https://www.youtube.com/watch?v=VIDEO_ID",
"language": "auto"
},
"mode": "transcription"
}'03 / CONTRACT
Seven small endpoints
/uploadsCreate a signed or resumable upload reservation.
/transcriptionsStart a file or YouTube transcription.
/transcriptionsList recent jobs with timestamp pagination.
/transcriptions/{id}Poll status and discover the result URL.
/transcriptions/{id}/resultReturn JSON, TXT, MD, CSV, SRT, or VTT.
/transcriptions/{id}Delete a completed or failed transcript early.
/creditsRead the current pay-as-you-go balance.
04 / RELIABILITY
Safe for retries
Idempotency
Generate one Idempotency-Key per intended POST. If the response is lost or times out, retry with the same key and unchanged body. Use a new key only for a new operation.
Server-observed billing
PlainScribe verifies the stored byte count and reads duration from media metadata before reserving credits.
Structured failures
Every API error includes a stable code, message, and request_id. Rate limits include Retry-After and standard RateLimit headers.
Asynchronous jobs
Submission returns 202. Poll every five seconds; result-not-ready responses explicitly return Retry-After: 5.
05 / AGENTS
Machine-readable by default
ChatGPT, Claude, Codex, OpenClaw, CLIs, and custom agents can inspect a canonical OpenAPI description or install the compact skill file. PlainScribe also publishes concise and expanded LLM context files.
06 / LIFECYCLE
Bytes do not linger
Uploaded media is deleted after success or failure, with a scheduled cleanup sweep as a backstop. Unconsumed uploads expire after 24 hours. Transcript artifacts expire after 30 days and can be deleted sooner through the API.
Review the full privacy policy before sending sensitive media.