Developers

The Dictation API

Speech in, clean text or a coding prompt out. Create a session, upload audio in parts while the user speaks, and finish. $60.00 an hour of audio, cleanup included, charged in credits on any plan.

Overview

The API is the same engine behind Sochen’s desktop app and composer. Audio is sent to Sochen in short WAV parts and passed to the AI model; each part is turned into text in memory and dropped. We never store audio or transcripts. The model provider doesn’t train on it (paid API). Runs on the AI models the account chooses, one to listen and one to write the words up; Sochen’s defaults are picked by our open benchmark.

  • Create a session from your server with an API key. You get a session id, a limit in seconds, and a client token you may hand to a browser.
  • Upload parts as the user speaks: cut the audio at pauses and post each piece with its index. The transcript of each part comes straight back, so most of the listening is done before they stop.
  • Finish with the joined transcript and a style: clean, prompt for coding agents, casual, formal, or verbatim.

Base URL: https://sochen.dev/api/v1/dictate. Every request and response is JSON except the audio upload. Any request may carry an app: a friendly name and a category (agent, code, terminal, chat, email, docs, browser, other), which picks the style from the account’s settings when you don’t pass one.

Authentication

Make a key on the Dictation page. It starts with sk_sochen_, is shown once, and goes in an Authorization: Bearer header. Keep it on your server: a key sent from a web page is refused, and there is no CORS on the endpoints that take one.

For browsers, every session comes with a clientToken (it starts with dst_). It opens only that session’s /transcribe and /finish, works for 15 minutes or until finish, and those two endpoints allow CORS from any origin when it is used.

Sessions

POST/sessions

Starts a dictation. Credits are checked up front against the session’s longest possible length, so a session that starts can always finish. Both fields are optional: language is a hint (auto, en, hinglish, hi, bn, ta, te, mr, gu, kn, ml, pa).

request
POST https://sochen.dev/api/v1/dictate/sessions
Authorization: Bearer sk_sochen_…
Content-Type: application/json

{
  "language": "auto",
  "app": { "name": "My editor", "category": "code" }
}
response 201
{
  "id": "dict_…",
  "expiresAt": "2026-09-27T10:15:00.000Z",
  "maxSeconds": 600,
  "stream": null,
  "clientToken": "…",
  "quota": { "plan": "pro", "wordsUsed": 12840, "wordsLimit": null, "resetsAt": "2026-10-01T00:00:00.000Z" },
  "settings": { "language": "auto", "output": "as-spoken", "accuracy": "fast", "styles": { "…": "…" }, "terms": [], "replacements": [], "snippets": [], "voiceSend": false, "transcribeModel": null, "cleanupModel": null }
}

maxSeconds is the most audio this session accepts. settings are the account’s, so a client can show the style it will use. stream is null unless a live provider is set up; see the optional live stream.

Upload audio parts

POST/sessions/:id/transcribe

Post a WAV file as the body, with x-part-index counting from 0. Cut at pauses: a part of five to sixty seconds is ideal, and each one comes back with its own transcript. Parts may be uploaded while the user is still speaking, one after another, and joined in index order at the end.

request
POST https://sochen.dev/api/v1/dictate/sessions/dict_…/transcribe
Authorization: Bearer <sk_sochen_… on a server, or the session's clientToken in a browser>
Content-Type: audio/wav
x-part-index: 0

<a WAV file: mono, 16-bit PCM, 8 to 48 kHz>
response
{ "transcript": "so in the auth middleware make the get user name function", "seconds": 6.4 }

The WAV must be mono, 16-bit PCM, 8 to 48 kHz (16 kHz is what Sochen’s own clients send). The seconds in the answer are what the session is charged for. A part is refused when it would take the session past maxSeconds. Without the header, a part counts as number 0. On paid plans, ?reason=best re-hears a part with the most accurate model after the fact.

Finish

POST/sessions/:id/finish

Send the transcript, joined from the parts, with the seconds of audio you recorded. The server applies the account’s replacements and snippets, strips a trailing “send it”, and writes the text out in the style. Once a session is finished it can’t be finished again, and its client token stops working.

request
POST https://sochen.dev/api/v1/dictate/sessions/dict_…/finish
Authorization: Bearer <sk_sochen_… or the clientToken>
Content-Type: application/json

{
  "transcript": "so in the auth middleware make the get user name function return null instead of throwing and add a test",
  "audioSeconds": 9,
  "app": { "name": "My editor", "category": "code" },
  "style": "prompt",
  "output": "english"
}
response
{
  "text": "In the auth middleware, make `getUserName` return null instead of throwing, and add a test.",
  "raw": "so in the auth middleware make the get user name function return null instead of throwing and add a test",
  "words": 20,
  "style": "prompt",
  "submit": false,
  "quota": { "plan": "pro", "wordsUsed": 12860, "wordsLimit": null, "resetsAt": "2026-10-01T00:00:00.000Z" }
}

To save a round trip, the last piece of audio can ride along with the finish: send multipart/form-data with the FinishRequest as a meta field and the WAV as audio. One model call then hears the tail and writes the whole dictation.

request, with the last part
POST https://sochen.dev/api/v1/dictate/sessions/dict_…/finish
Authorization: Bearer <sk_sochen_… or the clientToken>
Content-Type: multipart/form-data; boundary=…

--…
Content-Disposition: form-data; name="meta"

{ "transcript": "<the parts' transcripts so far>", "audioSeconds": 9, "style": "prompt" }
--…
Content-Disposition: form-data; name="audio"; filename="tail.wav"
Content-Type: audio/wav

<the last WAV part, heard and written up in one call>
--…--
  • style overrides the style the account’s settings give the app category. output is as-spoken, english or devanagari.
  • selection turns the dictation into an instruction about that text (“make this formal”); only the rewritten selection comes back.
  • variants asks for the same words in up to three more styles, returned under variants.
  • submit is true when the dictation ended with “send it” and the account has voice send on. Press Enter only in agent, chat or email apps, never in a terminal.

From a browser

Never put an API key in a page. Create the session on your server and hand the page the id, clientToken and maxSeconds. The page then uploads parts and finishes with Authorization: Bearer <clientToken>; both endpoints answer CORS preflights for it. The complete example below does exactly this.

Live stream (optional)

When the account has a live speech provider set up, stream in the session describes a WebSocket a client may open directly for word-by-word partials: the URL, and a first text message to send as is (it carries a short-lived, single-use credential locked to one model). After that, send raw 16 kHz 16-bit PCM frames as binary messages. Most integrations don’t need this: uploading parts as you go gives the same transcript a few seconds later, with nothing to keep open.

Clean text

POST/clean

Already have a transcript? Send the text and get it written out, with the account’s dictionary applied. Priced per thousand words; no audio is involved, and nothing counts against the dictation quota. The body takes the same optional fields as finish: style, output, app, selection and variants.

request
POST https://sochen.dev/api/v1/dictate/clean
Authorization: Bearer sk_sochen_…
Content-Type: application/json

{ "text": "um so can you, like, move the meeting to thursday no wait friday at 5", "style": "clean" }
response
{ "text": "Can you move the meeting to Friday at 5?", "words": 16, "style": "clean", "submit": false }

Limits

Audio part3.5 MB, about 100 s at 16 kHz
Parts per session12
Recording per sessionmaxSeconds from the session: 120 s on Free, 600 s on paid plans
Transcript20,000 characters
Selection (command mode)20,000 characters
Dictionary500 terms, 200 replacements, 100 snippets
Sessions30 a minute and 600 a day per account; 60 a minute per key. A session stays open for 15 minutes.
Parts60 a minute per account
Clean60 a minute per account
Keys10 active per account, 10 new ones an hour; made and revoked on the web only

Prices

  • Audio: 1 credit a minute, cleanup included. That is $60.00 an hour, since a credit is $0.01.
  • Clean: 0.1 credit per 1,000 words.
  • Usage is metered in thousandths of a credit and whole credits are taken from the account as they add up, on every plan. A session is refused with 402 when the balance can’t cover its longest possible length.

Credits come with every plan and from top-ups, which never expire. See pricing.

Errors

Errors are JSON with one plain sentence you can show as it is.

402
{ "error": "You've used this week's 2,000 free words. They refill on Monday, or upgrade for unlimited dictation." }
400Something in the request is wrong: the body, the WAV header, the part number.
401No key, a revoked key, or a client token that has expired or already finished.
402Credits can't cover the session's longest possible length, or the plan's words are used up.
403The account is frozen, an API key was sent from a web page, or best accuracy was asked for on Free.
404No such session, or it belongs to someone else.
409The session was already finished.
413The part, the transcript or the body is too large.
429Too many requests; wait a minute.
500Our side. Try again; nothing was charged.
502The speech or writing model didn't answer. Try again.

Complete example

A Node server that keeps the key and mints sessions, and a page that records with an AudioWorklet, uploads a part every ten seconds of speech, and finishes on release. Copy both files, set SOCHEN_API_KEY, run node server.mjs and open http://localhost:3000.

server.mjs
// server.mjs (Node 20+). Keeps the API key on the server; the browser gets a client token per session.
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";

const KEY = process.env.SOCHEN_API_KEY; // sk_sochen_…, from https://sochen.dev/dictation

createServer(async (req, res) => {
  if (req.method === "POST" && req.url === "/dictation/session") {
    const upstream = await fetch("https://sochen.dev/api/v1/dictate/sessions", {
      method: "POST",
      headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
      body: JSON.stringify({ language: "auto", app: { name: "My app", category: "docs" } }),
    });
    const session = await upstream.json();
    res.writeHead(upstream.status, { "content-type": "application/json" });
    // Only what the page needs: the id, the token that opens this one session, and the limit.
    res.end(JSON.stringify(upstream.ok ? { id: session.id, clientToken: session.clientToken, maxSeconds: session.maxSeconds } : session));
    return;
  }
  res.writeHead(200, { "content-type": "text/html" });
  res.end(await readFile("./page.html", "utf8"));
}).listen(3000);
page.html
<!-- page.html: hold the button, speak, let go. -->
<button id="talk">Hold to talk</button>
<p id="out"></p>
<script type="module">
const API = "https://sochen.dev/api/v1/dictate";
const RATE = 16000;

// The worklet turns the mic into 16 kHz 16-bit PCM, 100 ms at a time.
const worklet = URL.createObjectURL(new Blob([`
  class P extends AudioWorkletProcessor {
    constructor() { super(); this.buf = []; this.n = 0; }
    process(inputs) {
      const ch = inputs[0]?.[0]; if (!ch) return true;
      const out = new Int16Array(ch.length);
      for (let i = 0; i < ch.length; i++) { const s = Math.max(-1, Math.min(1, ch[i])); out[i] = s < 0 ? s * 0x8000 : s * 0x7fff; }
      this.buf.push(out); this.n += ch.length;
      if (this.n >= 1600) { this.port.postMessage(this.buf.splice(0)); this.n = 0; }
      return true;
    }
  }
  registerProcessor("pcm", P);`], { type: "application/javascript" }));

function wav(frames) {
  const bytes = frames.reduce((n, f) => n + f.byteLength, 0);
  const h = new DataView(new ArrayBuffer(44));
  const tag = (at, s) => [...s].forEach((c, i) => h.setUint8(at + i, c.charCodeAt(0)));
  tag(0, "RIFF"); h.setUint32(4, 36 + bytes, true); tag(8, "WAVE"); tag(12, "fmt "); h.setUint32(16, 16, true);
  h.setUint16(20, 1, true); h.setUint16(22, 1, true); h.setUint32(24, RATE, true); h.setUint32(28, RATE * 2, true);
  h.setUint16(32, 2, true); h.setUint16(34, 16, true); tag(36, "data"); h.setUint32(40, bytes, true);
  return new Blob([h.buffer, ...frames], { type: "audio/wav" });
}

let session, context, frames = [], partIndex = 0, uploads = Promise.resolve(), transcripts = [];

async function uploadPart(part) {
  const index = partIndex++;
  uploads = uploads.then(async () => {
    const r = await fetch(`${API}/sessions/${session.id}/transcribe`, {
      method: "POST",
      headers: { authorization: `Bearer ${session.clientToken}`, "content-type": "audio/wav", "x-part-index": String(index) },
      body: wav(part),
    });
    const { transcript } = await r.json();
    transcripts[index] = transcript;
    out.textContent = transcripts.join(" ");
  });
}

async function start() {
  session = await (await fetch("/dictation/session", { method: "POST" })).json();
  const mic = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
  context = new AudioContext({ sampleRate: RATE });
  await context.audioWorklet.addModule(worklet);
  const node = new AudioWorkletNode(context, "pcm");
  let seconds = 0;
  node.port.onmessage = (e) => {
    for (const f of e.data) { frames.push(f.buffer); seconds += f.length / RATE; }
    // Cut every ten seconds; the tail goes up on release.
    if (seconds >= 10) { uploadPart(frames.splice(0)); seconds = 0; }
  };
  context.createMediaStreamSource(mic).connect(node);
  context.mic = mic;
}

async function stop() {
  context.mic.getTracks().forEach((t) => t.stop());
  await context.close();
  if (frames.length) uploadPart(frames.splice(0));
  await uploads;
  const transcript = transcripts.join(" ");
  const done = await (await fetch(`${API}/sessions/${session.id}/finish`, {
    method: "POST",
    headers: { authorization: `Bearer ${session.clientToken}`, "content-type": "application/json" },
    body: JSON.stringify({ transcript, audioSeconds: Math.round(partIndex * 10), style: "clean" }),
  })).json();
  out.textContent = done.text;
  frames = []; partIndex = 0; transcripts = [];
}

const out = document.getElementById("out");
const talk = document.getElementById("talk");
talk.onpointerdown = start;
talk.onpointerup = stop;
</script>

Questions, or a language you need that isn’t here? Write to ndivij2004@gmail.com.