Dictation, in two calls.
Your user presses a button, speaks, presses it again, and gets clean text. You call start() and stop(). We handle everything in between.
import { Dictation } from 'https://docs.justunmute.me/sdk/unmute-dictation.mjs' const dictation = new Dictation({ token: getToken }) // token from your backend button.onclick = async () => { if (dictation.state === 'idle') return dictation.start() const { text } = await dictation.stop() editor.insert(text) }
Overview
This is the same dictation engine that runs inside the Unmute desktop app, offered as an API. The SDK streams the microphone to our servers while the person speaks. By the time they press stop, most of the work is already done.
It works in every modern browser and in Electron on macOS, Windows and Linux. Your code never handles audio.
Account & minutes
Request access with your work email. We review every request and email you when it's approved, with free minutes to start. The console is where you then create and revoke API keys, invite your team, follow usage, and try dictation live.
- What counts: seconds of audio we receive. A dictation that fails on our side is free.
- Running low: account owners get an email when fewer than 10 minutes are left, and another when the balance reaches zero. The console shows a banner too.
- At zero: new dictations are refused with the error
insufficient_balance(HTTP 402). A dictation already in progress always finishes, so nobody is cut off mid-sentence. - Staying ahead: every result includes
balance_seconds(balanceSecondsin the SDK), and uploads return anx-unmute-balance-secondsheader, so your app can warn early. - Top-ups: buy prepaid minute packs in the console under Add minutes (secure checkout by Dodo Payments). Minutes are added as soon as the payment clears and never expire.
Quickstart
- Get a secret key
Request access. Once approved, create a key in the console. It looks like
sk_live_…. It belongs on your server. Never ship it in a web page or inside an app bundle. - Add one endpoint to your backend
It exchanges your secret key for a short-lived client token (10 minutes by default) for the signed-in user. It's one HTTP call, in any language:
// Node 18+ (built-in fetch) app.post('/api/dictation-token', requireLogin, async (req, res) => { const r = await fetch('https://api.justunmute.me/v1/tokens', { method: 'POST', headers: { Authorization: `Bearer ${process.env.UNMUTE_SECRET_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ end_user_id: req.user.id }), // optional: shows up in your usage }) res.json({ token: (await r.json()).token }) })
r = requests.post("https://api.justunmute.me/v1/tokens", headers={"Authorization": f"Bearer {os.environ['UNMUTE_SECRET_KEY']}"}, json={"end_user_id": user.id}) token = r.json()["token"]
curl -X POST https://api.justunmute.me/v1/tokens \ -H "Authorization: Bearer $UNMUTE_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"end_user_id": "user_123"}' # → {"token":"ut_…","expires_at":"…","websocket_url":"wss://api.justunmute.me/v1/listen"}
- Add the SDK to your front end
Load it straight from us. It's 12 KB and has no dependencies. Either as a script tag:
<script src="https://docs.justunmute.me/sdk/unmute-dictation.min.js"></script> <script> const dictation = new UnmuteDictation.Dictation({ token: () => fetch('/api/dictation-token', { method: 'POST' }).then(r => r.json()).then(j => j.token), }) // on press: await dictation.start() // on release: const { text } = await dictation.stop() </script>
…or as an ES module (browsers and Electron renderers):import { Dictation } from 'https://docs.justunmute.me/sdk/unmute-dictation.mjs' const dictation = new Dictation({ token: () => fetch('/api/dictation-token', { method: 'POST' }) .then(r => r.json()).then(j => j.token), }) await dictation.start() // on press const { text } = await dictation.stop() // on release / second press
Using a bundler (webpack, Vite)? Download unmute-dictation.mjs into your project and import it from there. An npm package is coming soon.
That's the whole integration. Everything below is optional.
Try it
Mint a token with the curl command above, paste it here, then hold the button and speak.
Keys & tokens
| Credential | Where it lives | What it can do |
|---|---|---|
sk_live_… secret key | Your server only | Mint tokens, transcribe files, read usage. Sent as Authorization: Bearer sk_live_…. |
ut_… client token | Browser / Electron renderer | Start dictations until it expires (30 s – 1 h, default 10 min). Safe to expose. |
Revoking a secret key also invalidates every token minted from it, immediately. If you pass end_user_id when minting a token, each dictation is attributed to that user in your usage data.
token option. Both are deliberate: a key in client code can be read by anyone who has your app.Web apps
A push-to-talk button, with live coaching and a level meter:
const dictation = new Dictation({ token: getToken, prompt: 'Acme, Kubernetes, Priya' }) dictation.on('state', s => button.dataset.state = s) // idle · connecting · recording · processing dictation.on('level', rms => meter.style.width = Math.min(100, rms * 800) + '%') dictation.on('hint', h => toast(h.active ? h.message : null)) button.addEventListener('pointerdown', () => dictation.start().catch(showError)) button.addEventListener('pointerup', async () => { try { const r = await dictation.stop() if (r.text) textarea.value += r.text else if (r.dropped?.reason === 'quiet_capture') toast("Didn't catch that. Try again closer to the mic.") } catch (e) { showError(e) } })
start() resolves as soon as the microphone is live. The connection finishes in the background, and anything said in the meantime is buffered, so the first word isn't lost. Microphones need a secure page (https:// or localhost).
Electron apps
Use the SDK in the renderer exactly as on the web. There are two Electron-specific pieces, both about permissions:
const { app, session, systemPreferences } = require('electron') app.whenReady().then(async () => { // macOS: ask for microphone access (needs NSMicrophoneUsageDescription in Info.plist, // e.g. electron-builder: mac.extendInfo.NSMicrophoneUsageDescription) if (process.platform === 'darwin') await systemPreferences.askForMediaAccess('microphone') // Let your own pages use the mic session.defaultSession.setPermissionRequestHandler((wc, permission, cb) => cb(permission === 'media')) })
- Tokens: have the renderer fetch a token from your backend, as in the quickstart. If your app has no backend, the main process could call
/v1/tokensitself, but then the secret key ships inside the app, and anyone who unpacks the app can read it. Talk to us before choosing that route. - Hardened runtime (macOS): add the
com.apple.security.device.audio-inputentitlement. - Content Security Policy: allow
connect-src wss://api.justunmute.meandscript-src https://docs.justunmute.me(or bundle the SDK file yourself). The SDK loads its audio processor from ablob:URL. If your policy forbids that, it falls back automatically.
Clean mode
By default you get the words as spoken (mode: 'raw'), fixed only when the room was noisy. Set mode: 'clean' to always get well-formatted text that still reads as the speaker's own words:
const dictation = new Dictation({ token: getToken, mode: 'clean' })
| Spoken | Clean mode returns |
|---|---|
| um so for the grocery list we need eggs milk two loaves of bread and uh coffee beans | So for the grocery list we need: - eggs - milk - two loaves of bread - coffee beans |
| he told me quote we're not going to make the deadline unquote and honestly I think he's right | He told me, "We're not going to make the deadline," and honestly I think he's right. |
| I think I I think we agreed on three things first we move the launch second … | I think we agreed on three things: - First, we move the launch. - Second, … |
What clean mode does: it adds punctuation and capitals, puts quotation marks around speech the speaker attributes to someone, adds paragraph breaks, and turns spoken lists into - bullet lines (plain text with newlines). It removes only filler sounds (um, uh, er, hmm), repeated words and restarts ("I I", "I think I… I think"), spoken "quote / end quote", and the "and" that joined items that became bullets.
What it never does: add a word, drop a real word, swap one word for another, reorder words, or change numbers and negations. Every result is checked word by word against what was said. If the formatter strays, the speaker's words are put back automatically. Clean mode adds about 0.3–0.9 s after stop.
Coaching hints
We monitor the audio while the person speaks. When a problem shows up, you get a hint event with ready-made text, and another with active: false when it clears.
| code | When | message |
|---|---|---|
noisy_environment | Background noise is close to the speaker's level | It's noisy here. Move closer to the microphone or somewhere quieter. |
too_quiet | Even the loudest moment so far is faint | You're hard to hear. Move closer to the microphone. |
The SDK won't nag. Once a hint has been shown, it isn't repeated for 10 minutes across dictations (hintCooldownMs), unless the problem returns within the same dictation. A noisy room also turns on the correction pass automatically.
Transcribe a file
If you already have a finished recording (from a native app, say, or a server-side job), upload it and get the same pipeline and the same result in one request. It accepts WAV (any rate or channel count, PCM or float) or raw 16 kHz 16-bit mono PCM.
curl -X POST "https://api.justunmute.me/v1/transcriptions?language=en" \ -H "Authorization: Bearer $UNMUTE_SECRET_KEY" \ -H "Content-Type: audio/wav" \ --data-binary @note.wav
const r = await fetch('https://api.justunmute.me/v1/transcriptions?mode=clean', { method: 'POST', headers: { Authorization: `Bearer ${process.env.UNMUTE_SECRET_KEY}`, 'Content-Type': 'audio/wav' }, body: fs.readFileSync('note.wav'), }) const { text } = await r.json()
SDK reference
| Member | Description |
|---|---|
new Dictation(options) | Create once and reuse for every dictation. See options. |
start(): Promise<void> | Opens the mic and starts streaming. Rejects with mic_permission_denied, no_microphone or busy. |
stop(): Promise<DictationResult> | Stops the mic and resolves with the finished text. |
cancel() | Abandons the dictation. A pending stop() rejects with cancelled. |
state | 'idle' | 'connecting' | 'recording' | 'processing' |
on(event, fn) → unsubscribe | Events: state, level (0–1 rms, ~20/s), hint, speech (first speech detected), result, error (the connection failed while recording). |
Options
These are SDK options (camelCase). On the WebSocket and REST endpoints the same fields are snake_case.
| Option | Default | Description |
|---|---|---|
token | required | A client token, or a function (sync or async) that returns a fresh one. It's called once per dictation. |
mode | 'raw' | 'clean' always formats: punctuation, quotes, paragraphs, lists. See clean mode. |
language | 'en' | A supported Whisper language code ('es', 'hi', 'yue'…), or 'auto' to detect. Fetch GET /v1/languages for the full picker list. Region suffixes such as en-US use the base language (en). |
prompt | none | Names, product terms and spellings to favour, e.g. 'Acme, Kubernetes, Priya'. |
correction | true | Fixes misheard words, but only in noisy rooms. It adds nothing in a quiet room. |
removeFillers | false | Removes "um", "uh", stutters and abandoned false starts. It can only delete words, never add or change them. |
lowercase | false | Lowercases the output. |
maxDurationSeconds | 600 | Maximum length of one dictation (1–600). When it's reached, the result arrives on its own through the result event and the next stop(). |
deviceId | system default | A specific microphone, from navigator.mediaDevices.enumerateDevices(). |
hintCooldownMs | 600000 | How long a hint that was already shown stays quiet across dictations. 0 disables the cooldown. |
apiUrl | wss://api.justunmute.me | Override the endpoint, for example for staging. |
The result
{
text: "Book the flight to Lisbon for Tuesday morning.",
sessionId: "ses_4f1c…",
audioSeconds: 4.2,
speechDetected: true,
dropped: null, // or { reason: 'no_speech' | 'too_short' | 'quiet_capture', text? }
noisyEnvironment: false,
corrected: false, // the noisy-room pass changed at least one word
fillersRemoved: false,
mode: 'raw', // or 'clean'
formatted: false, // clean mode changed the formatting
processingMs: 380, // from stop to text
balanceSeconds: 3412 // prepaid seconds left on your account
}
text is empty when dropped is set:
no_speech: nothing was said.too_short: the recording was under half a second.quiet_capture: the recording barely rose above silence and produced only a few characters. That's almost always the recogniser guessing, so we withhold it. The guess is still available indropped.text.
In all three cases the right UI is "didn't catch that".
Errors
SDK errors are DictationError with a stable code. REST errors are {"error": {"code", "message"}} with the same codes.
| code | Meaning | What to do |
|---|---|---|
mic_permission_denied | The user or OS refused the microphone | Explain why you need it; point to system settings |
no_microphone / mic_unavailable | No input device, or not a secure page | Check https:// and the device |
token_error | Your token function threw or returned nothing | Check your token endpoint |
unauthorized | Token expired or invalid, or key revoked | Mint a fresh token (the token function does this per dictation) |
forbidden | A secret key was used where only a token is allowed | Move the key to your server |
invalid_request | Bad option or protocol misuse | The message says which field |
insufficient_balance | The account has no minutes left (HTTP 402) | Add minutes in the console; show the user "dictation is temporarily unavailable" |
rate_limited | Too many dictations started per minute on one key | Back off and retry |
transcription_failed | The speech provider failed after retries | Retry; the audio is not kept |
session_timeout | No audio for 20 seconds while recording | The mic stalled; start again |
connection_failed / timeout | Network trouble | Retry |
unsupported_audio / payload_too_large | File upload only | Send WAV or 16 kHz PCM, 50 MB / 10 min max |
WebSocket protocol
For platforms where the JavaScript SDK doesn't run (Swift, C#, Qt, Python…), speak the protocol directly. It's small.
- Connect to
wss://api.justunmute.me/v1/listen?token=ut_…. From a server you can sendAuthorization: Bearer sk_live_…instead (plus?end_user_id=if you like). - Send a text frame:
{"type":"start","language":"en"}. Any option from the table above can go here, in snake_case. - Wait for
{"type":"ready"}, then send binary frames of 16-bit little-endian mono PCM at 16 000 Hz. Any frame size works; about 100 ms (3 200 bytes) is ideal. Open the mic without echo cancellation, noise suppression or auto-gain. Our noise handling is tuned on raw audio. - Send
{"type":"stop"}. You'll get{"type":"final", …}(the result above, snake_case) and the socket closes with code 1000.
| Server message | Meaning |
|---|---|
ready | Start streaming. Includes session_id. |
speech_started | First speech detected (at_seconds) |
hint | {code, active, message}, see hints |
max_duration_reached | The duration limit was hit. final follows without a stop. |
final | The result. The socket then closes (1000). |
cancelled | Reply to {"type":"cancel"} |
error | {code, message}. The socket then closes (1008 / 1011). |
Authentication errors arrive inside the socket as an error message, never as a refused upgrade, so every client can read them.
REST endpoints
| Endpoint | Auth | Purpose |
|---|---|---|
GET /v1/languages | none | Supported language choices as {default_language, auto_detect, languages: [{code, name}]}. Use code in the dictation's language option. |
POST /v1/tokens | secret key | Body {"end_user_id"?, "expires_in"?} → {token, expires_at, websocket_url} |
GET /v1/listen | token or secret key | The WebSocket above |
POST /v1/transcriptions | secret key or token | One-shot file upload. Options go in the query string or as multipart fields (file + fields). |
GET /v1/usage?from=YYYY-MM-DD&to=YYYY-MM-DD | secret key | Your sessions and audio seconds, by status and by day. Defaults to this month. |
An unsupported language gets invalid_request before dictation starts (WebSocket error message, or HTTP 400 for an upload). Keep each user's language preference in your app and send it with each dictation; end_user_id on the token is only for usage attribution. Groq documents 99+ languages for whisper-large-v3-turbo; the code list follows OpenAI Whisper's tokenizer, because Groq does not publish an exhaustive code list.
Limits
- One dictation: up to 10 minutes of audio. File uploads: up to 50 MB and 10 minutes.
- 120 new dictations per minute per key. Ask us if you need more.
- Usage is measured in seconds of audio received. We don't store your audio or your transcripts; we keep only usage records (duration, status, timestamps).