PlainScribe API · v1

Transcription built for agents, scripts, and products.

Submit an audio or video file—or a YouTube URL—then poll one durable job and retrieve structured JSON, readable text, or ready-to-use subtitles. The API uses the same $4/hour credits as the PlainScribe dashboard.

Input

Files + YouTube

Maximum upload

1GB

Transcript retention

30 days

Authentication

User API keys

01 / QUICKSTART

From local file to transcript

  1. Create a user API key from your PlainScribe profile and store it as PLAINSCRIBE_API_KEY.
  2. Keep the key in a trusted server or local agent process. Never embed it in browser or mobile application code.
  3. That key can use every endpoint currently exposed under API v1; PlainScribe does not require per-endpoint scopes.
  4. Create an upload using the exact local byte size.
  5. 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.
  6. Start the transcription with the returned upload ID.
  7. Poll links.self; when complete, fetch links.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

POST/uploads

Create a signed or resumable upload reservation.

POST/transcriptions

Start a file or YouTube transcription.

GET/transcriptions

List recent jobs with timestamp pagination.

GET/transcriptions/{id}

Poll status and discover the result URL.

GET/transcriptions/{id}/result

Return JSON, TXT, MD, CSV, SRT, or VTT.

DELETE/transcriptions/{id}

Delete a completed or failed transcript early.

GET/credits

Read 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.