Unmute Dictation API Try it justunmute.me

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.

Speech gateChecks the audio against the room's own noise floor. If nothing was said, nothing is transcribed, so there's no invented text.
Pause-aware chunkingLong dictations are cut at natural pauses and transcribed while the person is still talking.
Context carry-overEach chunk is transcribed with the end of the previous one as context, so sentences stay coherent across cuts.
Hallucination cleanupRemoves phrases the recogniser invents from silence, such as "Thank you." or "Thanks for watching".
Noisy-room correctionIn noisy rooms, fixes misheard words. Only swaps between words that sound alike are allowed, and numbers and negations are never changed.
Live coachingTells you in real time when the room is noisy or the speaker is too quiet, so you can ask them to move closer to the mic.

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.

Quickstart

  1. 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.
  2. 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"}
  3. 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.

Paste a token to begin

Keys & tokens

CredentialWhere it livesWhat it can do
sk_live_… secret keyYour server onlyMint tokens, transcribe files, read usage. Sent as Authorization: Bearer sk_live_….
ut_… client tokenBrowser / Electron rendererStart 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.

Our API refuses a secret key in a URL, and the SDK refuses one in its 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'))
})

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' })
SpokenClean mode returns
um so for the grocery list we need eggs milk two loaves of bread and uh coffee beansSo 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 rightHe 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.

codeWhenmessage
noisy_environmentBackground noise is close to the speaker's levelIt's noisy here. Move closer to the microphone or somewhere quieter.
too_quietEven the loudest moment so far is faintYou'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

MemberDescription
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) → unsubscribeEvents: 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.

OptionDefaultDescription
tokenrequiredA 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).
promptnoneNames, product terms and spellings to favour, e.g. 'Acme, Kubernetes, Priya'.
correctiontrueFixes misheard words, but only in noisy rooms. It adds nothing in a quiet room.
removeFillersfalseRemoves "um", "uh", stutters and abandoned false starts. It can only delete words, never add or change them.
lowercasefalseLowercases the output.
maxDurationSeconds600Maximum length of one dictation (1–600). When it's reached, the result arrives on its own through the result event and the next stop().
deviceIdsystem defaultA specific microphone, from navigator.mediaDevices.enumerateDevices().
hintCooldownMs600000How long a hint that was already shown stays quiet across dictations. 0 disables the cooldown.
apiUrlwss://api.justunmute.meOverride 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:

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.

codeMeaningWhat to do
mic_permission_deniedThe user or OS refused the microphoneExplain why you need it; point to system settings
no_microphone / mic_unavailableNo input device, or not a secure pageCheck https:// and the device
token_errorYour token function threw or returned nothingCheck your token endpoint
unauthorizedToken expired or invalid, or key revokedMint a fresh token (the token function does this per dictation)
forbiddenA secret key was used where only a token is allowedMove the key to your server
invalid_requestBad option or protocol misuseThe message says which field
insufficient_balanceThe account has no minutes left (HTTP 402)Add minutes in the console; show the user "dictation is temporarily unavailable"
rate_limitedToo many dictations started per minute on one keyBack off and retry
transcription_failedThe speech provider failed after retriesRetry; the audio is not kept
session_timeoutNo audio for 20 seconds while recordingThe mic stalled; start again
connection_failed / timeoutNetwork troubleRetry
unsupported_audio / payload_too_largeFile upload onlySend 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.

  1. Connect to wss://api.justunmute.me/v1/listen?token=ut_…. From a server you can send Authorization: Bearer sk_live_… instead (plus ?end_user_id= if you like).
  2. Send a text frame: {"type":"start","language":"en"}. Any option from the table above can go here, in snake_case.
  3. 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.
  4. Send {"type":"stop"}. You'll get {"type":"final", …} (the result above, snake_case) and the socket closes with code 1000.
Server messageMeaning
readyStart streaming. Includes session_id.
speech_startedFirst speech detected (at_seconds)
hint{code, active, message}, see hints
max_duration_reachedThe duration limit was hit. final follows without a stop.
finalThe result. The socket then closes (1000).
cancelledReply 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

EndpointAuthPurpose
GET /v1/languagesnoneSupported language choices as {default_language, auto_detect, languages: [{code, name}]}. Use code in the dictation's language option.
POST /v1/tokenssecret keyBody {"end_user_id"?, "expires_in"?} → {token, expires_at, websocket_url}
GET /v1/listentoken or secret keyThe WebSocket above
POST /v1/transcriptionssecret key or tokenOne-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-DDsecret keyYour 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