Docs · Speech-to-Text API
Saidwire Speech-to-Text API
Real-time and pre-recorded English speech-to-text.
| Streaming | wss://api.saidwire.com/v1/listen (WebSocket) |
| Pre-recorded | POST https://api.saidwire.com/v1/listen |
| Status | GET https://api.saidwire.com/health |
| Auth | Authorization: Token <API key> |
The API follows the widely used /v1/listen schema (query parameters, Results messages, KeepAlive / Finalize / CloseStream), so most existing streaming clients and voice-agent integrations work by changing only the base URL and the API key. The differences are listed in Compatibility notes.
Each streaming result comes in two passes. Interim results (is_final: false) come from a fast first pass while the person is speaking. When they pause, a second pass re-reads the whole utterance and returns the final result (is_final: true). Final text can therefore correct words that the interims got wrong.
Availability: while the service is in evaluation it runs in scheduled windows. Outside a window every request gets HTTP 503 with err_code SERVICE_OFFLINE. GET /health shows the current state.
---
Quickstart
1. Pre-recorded file (curl)
export SAIDWIRE_API_KEY=sk_live_xxxxxxxx.yyyyyyyyyyyyyyyy curl -s -X POST "https://api.saidwire.com/v1/listen?punctuate=true" \ -H "Authorization: Token $SAIDWIRE_API_KEY" \ -H "Content-Type: audio/wav" \ --data-binary @meeting.wav | jq -r '.results.channels[0].alternatives[0].transcript'
You can send WAV, MP3, FLAC, Ogg/Opus, WebM, M4A/MP4, or raw PCM. For raw PCM add ?encoding=linear16&sample_rate=16000. To have the server download a public file, send JSON instead: -H "Content-Type: application/json" -d '{"url": "https://example.com/a.mp3"}'.
2. Streaming (Python)
# pip install websockets
import asyncio, json, os, wave
from websockets.asyncio.client import connect
URL = ("wss://api.saidwire.com/v1/listen?encoding=linear16&sample_rate=16000"
"&interim_results=true&punctuate=true&endpointing=300&utterance_end_ms=1000")
async def main(path):
with wave.open(path) as w: # 16 kHz, mono, 16-bit
pcm = w.readframes(w.getnframes())
async with connect(URL, additional_headers={"Authorization": f"Token {os.environ['SAIDWIRE_API_KEY']}"}) as ws:
async def reader():
async for raw in ws:
m = json.loads(raw)
if m["type"] == "Results" and m["is_final"]:
print(m["channel"]["alternatives"][0]["transcript"])
r = asyncio.create_task(reader())
for i in range(0, len(pcm), 640): # 20 ms frames, real time
await ws.send(pcm[i:i + 640])
await asyncio.sleep(0.02)
await ws.send(json.dumps({"type": "CloseStream"}))
await r
asyncio.run(main("call.wav"))
3. Streaming (JavaScript, Node)
// npm install ws
const WebSocket = require("ws");
const ws = new WebSocket(
"wss://api.saidwire.com/v1/listen?encoding=linear16&sample_rate=16000&interim_results=true&punctuate=true",
{ headers: { Authorization: `Token ${process.env.SAIDWIRE_API_KEY}` } });
ws.on("message", (raw) => {
const m = JSON.parse(raw);
if (m.type === "Results") console.log(m.is_final ? "final " : "interim", m.channel.alternatives[0].transcript);
});
ws.on("open", () => {
// send binary 16-bit PCM frames (20-100 ms each) as they are captured, then:
// ws.send(JSON.stringify({ type: "CloseStream" }));
});
In a browser, pass the key as a subprotocol, because browsers cannot set headers on a WebSocket: new WebSocket(url, ["token", key]). You can send MediaRecorder chunks (audio/webm;codecs=opus) as they are, without an encoding parameter. A key in page source can be seen by anyone, so use a separate key with low limits.
Need a fuller example, or help switching an existing client? Email support@saidwire.com and we'll pair with you.
---
Authentication
Send the key in one of three ways:
| how | example |
|---|---|
| header (servers) | Authorization: Token sk_live_... (Bearer also works) |
| query string | wss://api.saidwire.com/v1/listen?token=sk_live_... |
| WebSocket subprotocol (browsers) | new WebSocket(url, ["token", "sk_live_..."]) |
A key looks like sk_live_<id>.<secret>. The <id> part is public. It appears in usage reports and support requests. The whole key is secret. Saidwire stores only a hash of it, so a lost key cannot be recovered and has to be replaced.
---
Streaming: GET /v1/listen (WebSocket)
Query parameters
| parameter | values | default |
|---|---|---|
encoding | linear16, mulaw, alaw, linear32, opus (Ogg-Opus), flac, mp3, webm, ogg. Omit it to send a WAV / Ogg / WebM / FLAC / MP3 stream, and the format is detected. | detected |
sample_rate | 8000-48000 (resampled internally) | 16000 (mulaw/alaw: 8000) |
channels | 1 | 1 |
interim_results | true / false | false |
punctuate | true / false: punctuation and capitals in transcript | false |
smart_format | same as punctuate | false |
endpointing | ms of pause that ends an utterance (speech_final: true), or false | 800 (minimum 500) |
utterance_end_ms | ms of no speech before an UtteranceEnd message (100-10000) | off |
vad_events | true adds SpeechStarted messages | false |
language | en, en-US, en-GB, ... (English only) | en |
early_words | true adds EarlyWord messages (extension, see below) | false |
Other parameters are accepted and ignored, so existing clients connect without changes. These include model, version, tier, keywords, keyterm, diarize, filler_words, numerals, profanity_filter, redact, search, replace, tag, multichannel, alternatives, no_delay, mip_opt_out and dictation.
Client → server
- Binary frames: audio in the declared encoding. Any frame size works; 20-100 ms is typical. Send audio in real time.
{"type": "KeepAlive"}: keeps a silent connection open. A stream that receives no audio and no KeepAlive for 10 s is closed.{"type": "Finalize"}: transcribe everything sent so far, right now. The answer is a finalResultswith"from_finalize": true. It is always sent, even with an empty transcript. The stream stays open. Use this when your own voice-activity detector decides the user has stopped talking.{"type": "CloseStream"}: flush, then receive the last finalResultsand aMetadatamessage. The socket then closes with code 1000. An empty binary frame does the same.
Server → client
**Results**: one interim or final transcript of the current utterance.
{"type": "Results", "channel_index": [0, 1], "start": 3.1, "duration": 2.42,
"is_final": true, "speech_final": true, "from_finalize": false,
"channel": {"alternatives": [{"transcript": "I'd like to book a table for two.", "confidence": 0.95,
"words": [{"word": "i'd", "start": 3.18, "end": 3.41, "confidence": 0.95, "punctuated_word": "I'd"}]}]},
"metadata": {"request_id": "6f0c...", "model_info": {"name": "saidwire-en", "version": "2026-10-30", "arch": "streaming"},
"model_uuid": "..."}}
- All times are seconds of audio since the stream opened.
is_final: false(interim) messages give the current guess for the open utterance. Each one replaces the one before. They are sent only withinterim_results=true.is_final: true(final) messages contain an utterance's words, which will not change. Consecutive final windows[start, start + duration)tile the stream.speech_final: truemarks a final that ended because of a pause of at leastendpointingms. Treat it as the end of the user's turn.- Other finals have
speech_final: false. These are finals answeringFinalizeorCloseStream, finals after 20 s of continuous speech, and finals whenendpointing=false.
**UtteranceEnd**: no speech for utterance_end_ms. It always arrives after the final for the words it covers.
{"type": "UtteranceEnd", "channel": [0, 1], "last_word_end": 5.52}
**SpeechStarted**: vad_events=true only. It is an energy-based hint that speech began, for barge-in.
{"type": "SpeechStarted", "channel": [0, 1], "timestamp": 7.25}
**Metadata**: sent once, just before a normal close.
{"type": "Metadata", "request_id": "6f0c...", "created": "2026-10-30T14:02:11.120+00:00", "duration": 14.39,
"channels": 1, "models": ["..."], "model_info": {"...": {"name": "saidwire-en", "version": "2026-10-30", "arch": "streaming"}}}
**Error**: sent before the server closes the stream (see Errors).
{"type": "Error", "err_code": "IDLE_TIMEOUT", "err_msg": "No audio or KeepAlive received within the timeout window.",
"description": "...", "request_id": "6f0c..."}
**EarlyWord** (extension; early_words=true): each word as soon as the first pass commits it. This is usually well before the final that contains the word. Use it to start planning a reply, or to react to keywords such as "stop" or "cancel". The final Results is authoritative, and its text can differ.
{"type": "EarlyWord", "channel_index": [0, 1], "word": "tomorrow", "punctuated_word": "tomorrow.",
"start": 6.84, "end": 7.29, "confidence": 0.95, "audio_end": 7.95, "committed_by": "engine"}
Clients that do not know this message type can leave it turned off.
End of turn
- The utterance ends once
endpointingms pass with no new speech after the last word. The final for it then arrives withspeech_final: true. Finalizeends the open utterance immediately, whatever the pause.endpointing=false: finals are still produced, at longer pauses (about 1 s) and on Finalize, butspeech_finalis alwaysfalse. Use this when your own endpointing model decides turns and callsFinalize.
---
Pre-recorded: POST /v1/listen
The body is the audio file itself, in any common format (WAV, MP3, FLAC, Ogg/Opus, WebM, M4A/MP4, AAC). It can also be raw PCM, with encoding and sample_rate. Or send Content-Type: application/json with {"url": "https://..."} for a publicly reachable file. The request waits for the whole result.
Query parameters: punctuate, smart_format, encoding, sample_rate, language (as for streaming), plus utterances=true (adds results.utterances) and utt_split (seconds of pause between utterances, default 0.8). Other parameters are accepted and ignored.
{"metadata": {"request_id": "1b2e...", "sha256": "...", "created": "2026-10-30T14:05:00.001+00:00", "duration": 61.2,
"channels": 1, "models": ["..."], "model_info": {"...": {"name": "saidwire-en", "version": "2026-10-30", "arch": "batch"}}},
"results": {"channels": [{"alternatives": [{"transcript": "...", "confidence": 0.95,
"words": [{"word": "hello", "start": 0.48, "end": 0.81,
"confidence": 0.95, "punctuated_word": "Hello,"}]}]}],
"utterances": [{"start": 0.48, "end": 3.9, "confidence": 0.95, "channel": 0, "transcript": "...",
"words": ["..."], "id": "..."}]}}
Limits: 100 MB per request and 60 minutes of audio. URL downloads time out after 30 s, and only public addresses are fetched.
---
Errors
A refused request gets a plain HTTP response with a JSON body. For WebSocket requests this happens instead of the upgrade.
{"err_code": "INVALID_AUTH", "err_msg": "Invalid credentials.", "request_id": "..."}
| HTTP | err_code | meaning |
|---|---|---|
| 400 | BAD_REQUEST, UNSUPPORTED_AUDIO, UNSUPPORTED_LANGUAGE, REMOTE_CONTENT_ERROR | fix the request |
| 401 | INVALID_AUTH | missing or unknown key |
| 402 | QUOTA_EXCEEDED | the key's monthly minutes are used up |
| 403 | KEY_REVOKED | the key was revoked |
| 404 / 405 | NOT_FOUND, METHOD_NOT_ALLOWED | wrong path or method |
| 413 | PAYLOAD_TOO_LARGE, AUDIO_TOO_LONG | pre-recorded size / length limits |
| 429 | TOO_MANY_REQUESTS (per-key concurrency), RATE_LIMITED (new streams per minute, or failed logins), TOO_MANY_STREAMS (service at capacity) | retry after the Retry-After header |
| 503 | ENGINE_UNAVAILABLE, SERVICE_DRAINING, SERVICE_OFFLINE, AUTH_UNAVAILABLE | temporary; retry after Retry-After |
During a stream, the server sends an Error message and then closes the socket.
err_code | close code |
|---|---|
IDLE_TIMEOUT (10 s without audio or KeepAlive), STREAM_TOO_LONG (4 h), UNSUPPORTED_AUDIO, QUOTA_EXCEEDED, KEY_REVOKED | 1008 |
ENGINE_OVERLOADED (audio arrives faster than it can be transcribed), ENGINE_UNAVAILABLE, INTERNAL | 1011 |
SERVER_SHUTDOWN (maintenance restart; reconnect) | 1012 |
BAD_MESSAGE (an unknown text message) is reported but does not close the stream. Every request has a request_id. It is in the x-request-id response header, in Metadata, in every Results.metadata and in every error. Please include it when you contact support.
Limits
| limit | default per key |
|---|---|
| concurrent streams | 4 (raised for evaluation keys) |
| new streams per minute | 60 |
| concurrent pre-recorded requests | 2 |
| monthly minutes | set per key |
| idle stream | closed after 10 s without audio or KeepAlive |
| stream length | 4 hours |
| pre-recorded request | 100 MB, 60 minutes of audio |
Usage is the number of seconds of audio received, silence included. Streams are measured from the first byte to the close. Pre-recorded requests are measured by the decoded duration.
Health
GET /health returns {"status": "ok" | "degraded" | "draining", "version", "streams", "capacity", ...}. No key is needed.
Compatibility notes
The following differ from the common /v1/listen protocol.
- English only. Mono only (
channels=1). - Not supported: speaker diarization, multichannel, keyword / key-term boosting, redaction, search / replace, profanity filtering, language detection, more than one alternative, callbacks, paragraphs and summaries.
smart_formatadds punctuation and capitals, but does not format numbers, currencies or dates. confidenceis a fixed value (0.95). It is not a calibrated score.- Interim and final text come from two passes, so a final can differ from the interims before it (see the top of this page).
endpointingvalues below 500 ms are treated as 500 ms, and the default is 800 ms. Shorter pauses cut fluent speech into fragments and cost accuracy. For a faster end of turn, run your own voice-activity detection and sendFinalize. That is answered right away and does not wait for a pause. An utterance also gets a final after 20 s of continuous speech.- There are no periodic empty finals during long silence. Finals are sent only for utterances that contain speech, and for every
Finalize(which is always answered). UtteranceEnddoes not requireinterim_results. The minimumutterance_end_msis 100.Metadatais sent at the end of the stream only.- Error codes and close codes are the ones listed above.
- Compressed live audio (Opus, MP3, FLAC) is decoded on the server, which adds some delay: about 0.1-0.3 s for Opus and MP3, and more for FLAC. For the lowest latency send
linear16(ormulawfor telephony). EarlyWordis an extension.
Data handling
Audio is processed in memory and never written to disk. Transcripts are not stored or logged. Usage records keep only the key id, the time and the number of seconds.
Questions or something unclear? Email support@saidwire.com with your request_id.