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,promptfor coding agents,casual,formal, orverbatim.
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).
POST https://sochen.dev/api/v1/dictate/sessions
Authorization: Bearer sk_sochen_…
Content-Type: application/json
{
"language": "auto",
"app": { "name": "My editor", "category": "code" }
}{
"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.
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>{ "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.
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"
}{
"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.
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>
--…--styleoverrides the style the account’s settings give theappcategory.outputisas-spoken,englishordevanagari.selectionturns the dictation into an instruction about that text (“make this formal”); only the rewritten selection comes back.variantsasks for the same words in up to three more styles, returned undervariants.submitis 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.
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" }{ "text": "Can you move the meeting to Friday at 5?", "words": 16, "style": "clean", "submit": false }Limits
| Audio part | 3.5 MB, about 100 s at 16 kHz |
| Parts per session | 12 |
| Recording per session | maxSeconds from the session: 120 s on Free, 600 s on paid plans |
| Transcript | 20,000 characters |
| Selection (command mode) | 20,000 characters |
| Dictionary | 500 terms, 200 replacements, 100 snippets |
| Sessions | 30 a minute and 600 a day per account; 60 a minute per key. A session stays open for 15 minutes. |
| Parts | 60 a minute per account |
| Clean | 60 a minute per account |
| Keys | 10 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.
{ "error": "You've used this week's 2,000 free words. They refill on Monday, or upgrade for unlimited dictation." }| 400 | Something in the request is wrong: the body, the WAV header, the part number. |
| 401 | No key, a revoked key, or a client token that has expired or already finished. |
| 402 | Credits can't cover the session's longest possible length, or the plan's words are used up. |
| 403 | The account is frozen, an API key was sent from a web page, or best accuracy was asked for on Free. |
| 404 | No such session, or it belongs to someone else. |
| 409 | The session was already finished. |
| 413 | The part, the transcript or the body is too large. |
| 429 | Too many requests; wait a minute. |
| 500 | Our side. Try again; nothing was charged. |
| 502 | The 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 (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: 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.