Docs · Speech-to-Text API

Saidwire Speech-to-Text API

Real-time and pre-recorded English speech-to-text.

Streamingwss://api.saidwire.com/v1/listen (WebSocket)
Pre-recordedPOST https://api.saidwire.com/v1/listen
StatusGET https://api.saidwire.com/health
AuthAuthorization: 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:

howexample
header (servers)Authorization: Token sk_live_... (Bearer also works)
query stringwss://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

parametervaluesdefault
encodinglinear16, 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_rate8000-48000 (resampled internally)16000 (mulaw/alaw: 8000)
channels11
interim_resultstrue / falsefalse
punctuatetrue / false: punctuation and capitals in transcriptfalse
smart_formatsame as punctuatefalse
endpointingms of pause that ends an utterance (speech_final: true), or false800 (minimum 500)
utterance_end_msms of no speech before an UtteranceEnd message (100-10000)off
vad_eventstrue adds SpeechStarted messagesfalse
languageen, en-US, en-GB, ... (English only)en
early_wordstrue 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

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": "..."}}

**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

---

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": "..."}
HTTPerr_codemeaning
400BAD_REQUEST, UNSUPPORTED_AUDIO, UNSUPPORTED_LANGUAGE, REMOTE_CONTENT_ERRORfix the request
401INVALID_AUTHmissing or unknown key
402QUOTA_EXCEEDEDthe key's monthly minutes are used up
403KEY_REVOKEDthe key was revoked
404 / 405NOT_FOUND, METHOD_NOT_ALLOWEDwrong path or method
413PAYLOAD_TOO_LARGE, AUDIO_TOO_LONGpre-recorded size / length limits
429TOO_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
503ENGINE_UNAVAILABLE, SERVICE_DRAINING, SERVICE_OFFLINE, AUTH_UNAVAILABLEtemporary; retry after Retry-After

During a stream, the server sends an Error message and then closes the socket.

err_codeclose code
IDLE_TIMEOUT (10 s without audio or KeepAlive), STREAM_TOO_LONG (4 h), UNSUPPORTED_AUDIO, QUOTA_EXCEEDED, KEY_REVOKED1008
ENGINE_OVERLOADED (audio arrives faster than it can be transcribed), ENGINE_UNAVAILABLE, INTERNAL1011
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

limitdefault per key
concurrent streams4 (raised for evaluation keys)
new streams per minute60
concurrent pre-recorded requests2
monthly minutesset per key
idle streamclosed after 10 s without audio or KeepAlive
stream length4 hours
pre-recorded request100 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.

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.