All tutorials
AI Voice Address Capture: Resolve Spoken Addresses with Swiftcomplete
Swiftcomplete’s voice address API turns a caller saying their address into a validated UK
postal address. Stream the caller’s audio to us over a WebSocket and you get back the
address in the same format as the Swiftcomplete Validate API. It’s built for voice agents,
contact centres and IVR systems, and all you need is a Swiftcomplete API key.
See it in action
Try the voice demo at demo.what3words.com
- Your server opens a WebSocket for one address and sends
start_utterance. - You stream the caller’s audio as it arrives. Transcripts of what we hear come back
while the caller speaks. - When the caller has finished, you send
commit_utterance. - You receive exactly one result, and the connection closes.
Each connection handles one address. To capture another address, or to try again, open a
new connection.
All requests require an API key passed in the X-Api-Key header.
Keep your key on your server. Browsers can’t add custom headers to a WebSocket connection,
so always connect from your backend, never from a web page or mobile app. If you capture the
caller’s audio in a browser or app, relay it through your server.
Send raw mono audio (no file header or container) in one of these formats, at its original
sample rate. There’s no need to resample it.
| encoding | sample_rate_hz | Typical source |
|---|---|---|
| pcm_s16le | 48000 | Browser and app microphones |
| pcm_s16le | 16000 | Wideband (HD) voice and many voice AI platforms |
| pcm_s16le | 8000 | Telephone audio decoded to 16-bit PCM |
| mulaw | 8000 | Telephone audio (G.711 μ-law), common in North America and Japan |
| alaw | 8000 | Telephone audio (G.711 A-law), common in Europe |
pcm_s16le is signed 16-bit little-endian PCM. mulaw and alaw are G.711, one byte per
sample. Always tell us which format you’re sending, in start_utterance (step 6).
Send frames of up to four seconds of audio. We recommend 20 ms per frame, for example 160
bytes of 8 kHz mulaw, 640 bytes of 16 kHz pcm_s16le or 1,920 bytes of 48 kHz
pcm_s16le.
To convert a recording for testing, you can use FFmpeg:
ffmpeg -i input.wav -ac 1 -ar 16000 -c:a pcm_s16le address.wav
Open a WebSocket to:
wss://voice.swiftcomplete.com/v1/address/stream
Send your key in the X-API-Key header:
x-api-key: YOUR_API_KEY
You can open the connection before the caller starts speaking, for example while your
agent asks for their address. You must send start_utterance within 15 seconds of
connecting.
If we can’t accept the connection, the WebSocket upgrade fails with an HTTP status:
| Status | Meaning | What to do |
|---|---|---|
| 401 | The key is missing or malformed, or Swiftcomplete doesn't recognise it. | Check that the key is copied exactly and sent in X-API-Key. |
| 403 | Swiftcomplete recognises the key but won't accept it, for example because its free trial has ended or it is restricted to URLs or IP addresses. | Read the description, then check your account or use an unrestricted key. |
| 429 | The key already has four connections open, or the service has too many connections waiting to start. | Retry after the number of seconds in Retry-After. |
| 503 | The service is temporarily unavailable. | Retry with backoff. |
A 401 or 403 for a key in the right format comes with a JSON body from Swiftcomplete
that explains why, the same as the Validate API’s:
{ "parameter": "key", "description": "Your credits have expired. Please sign in to your account and purchase credits to continue using the service.", "resolutionExamples": [] }
Show or log the description. Its wording comes from Swiftcomplete and may change, so
don’t match on it. A missing or malformed key gets a 401 with no body.
Start
Send one start_utterance message as JSON text:
{ "type": "start_utterance", "audio_format": { "encoding": "mulaw", "sample_rate_hz": 8000 }, "idempotency_key": "5f0c2a8e-7d1b-4c3e-9a2f-1b6d8e4c0a7f" }
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | Yes | "start_utterance" |
| audio_format | object | Yes | The format you'll send: encoding and sample_rate_hz, as in step 4. Without it, the request ends with protocol_error. |
| idempotency_key | string | No | Identifies this address across retries, for example a UUID: 1 to 128 printable ASCII characters, without spaces. See <a href="#retries">Retries</a>. |
We ignore fields we don’t recognise, so a client that sends extra fields keeps working.
Send audio
Send the caller’s audio as binary WebSocket messages, in order, as it arrives. Each address
can be up to 60 seconds of audio.
Commit
When the caller has finished saying their address, send:
{ "type": "commit_utterance" }
Commit as soon as you’re confident the caller has finished, for example when your voice
activity detection hears them stop. Callers often pause part-way through an address, for
example before the postcode, so don’t commit at the first short pause: anything said after
the commit is lost. This matters most on phone audio, where pauses are harder to tell from
quiet speech. The result follows the commit. Don’t send any more audio afterwards.
Transcripts
You receive transcripts while the caller speaks:
{"type":"transcript_interim","transcript":"ten downing"} {"type":"transcript_interim","transcript":"ten downing street"} {"type":"transcript_final","transcript":"ten downing street london"}
- Each interim transcript holds everything heard so far and replaces the one before it.
- There is at most one final transcript, sent after you commit. Don’t rely on receiving it.
- They are raw speech-to-text output, unformatted and possibly with recognition errors. Use
them for display, such as live captions or context for your agent, or ignore them. Never
use them as the address: the result carries the address.
After you commit, you receive exactly one of these three events, and then the connection
closes. We recommend a client timeout of 15 seconds after the commit.
validation_completed: an address was found
{ "type": "validation_completed", "turn_id": "9fb7c1e2-4d0a-4c55-a0f3-2b8e6d1f7a90", "response": { "validation": { "result": "match", "resolution": "premise" }, "address": { "singleLine": "65 Alfred Road, London, W2 5EU, United Kingdom", "multiLine": { "line1": "65 Alfred Road", "primaryLocality": "London", "postalCode": "W2 5EU" } }, "associatedRecords": null } }
response is exactly what the Swiftcomplete Validate API returns for this address, so you
can handle it with the same code.
validation.resultismatch,suggestionornone.validation.resolutionis the level the address was matched to, for examplepremise
orstreet.addressholds the formatted address, both on a single line and split into lines.
turn_id identifies this request. Include it if you contact support.
not_resolved: no address could be identified
{"type":"not_resolved","turn_id":"9fb7c1e2-4d0a-4c55-a0f3-2b8e6d1f7a90"}
We couldn’t identify the address confidently enough, so we don’t return a guess. Ask the
caller again on a new connection, for example asking them to say the postcode clearly.
error: the request failed
{"type":"error","turn_id":"9fb7c1e2-4d0a-4c55-a0f3-2b8e6d1f7a90","code":"service_busy","retry_after_ms":1000}
| code | Meaning | What to do |
|---|---|---|
| invalid_audio | The audio wasn't in the required format, or you committed without sending any. | Check your audio against step 4. |
| utterance_limit_exceeded | A frame held more than 4 seconds of audio, or the address more than 60 seconds. | Send smaller frames, and capture only the address. |
| no_speech | We heard no speech. | Ask the caller to say their address again. |
| protocol_error | Messages arrived out of order or couldn't be read, for example audio before start_utterance or a second start_utterance. | Fix your client. |
| service_busy | The service is at capacity. No work was done. | Retry after retry_after_ms milliseconds. |
| api_key_rejected | Swiftcomplete stopped accepting your key during the request, for example because your credits ran out or your free trial ended. The address isn't returned. | Read the description and check your account. Retrying won't help until it's fixed. |
| service_unavailable | A temporary failure on our side. | Retry once (see below). If it keeps happening, contact us with the turn_id and error_ref. |
An api_key_rejected error also has a description from Swiftcomplete, for example:
{"type":"error","turn_id":"9fb7c1e2-4d0a-4c55-a0f3-2b8e6d1f7a90","code":"api_key_rejected","description":"Your free trial has expired. Please sign in to your account and purchase credits to continue using the service."}
A service_unavailable error also has an error_ref, such as "error_ref":"E954", which
helps our support team find the cause. New values may be added, so don’t write code that
depends on particular ones.
To retry, open a new connection and either ask the caller again or resend the audio you
captured, at its original pace.
If a network failure means you didn’t receive the result, retry with the same
idempotency_key. For 120 seconds after a result, the same key returns that result straight
away without processing the audio again. A key whose request ended in an error isn’t kept,
so a retry with it is processed afresh. If the original request is still in progress, you
receive service_busy. Use a new key for every new address, because a reused key returns
the earlier result.
| Limit | Value |
|---|---|
| Addresses per connection | 1 |
| Time to send start_utterance after connecting | 15 seconds |
| Audio per address | 60 seconds |
| Time from start_utterance to commit_utterance | 120 seconds |
| Frame size | 4 seconds of audio |
| Simultaneous connections per key | 4. Contact us if you need more. |
While a request is active, the server sends a WebSocket ping every 20 seconds. Standard
WebSocket libraries answer automatically.
The API resolves UK postal addresses spoken in English, one address per connection.
Both examples stream a WAV file at real-time pace, as a live call would, and print the
result. The WAV file must be 16-bit mono PCM at 8, 16 or 48 kHz (see step 4).
Python
"""Resolve a spoken UK address from a WAV file with Swiftcomplete. Usage: pip install "websockets>=14" export SWIFTCOMPLETE_API_KEY=your-key python resolve_address.py address.wav """ import asyncio import json import os import sys import uuid import wave import websockets URL = "wss://voice.swiftcomplete.com/v1/address/stream" SAMPLE_RATES = (8_000, 16_000, 48_000) FRAME_MS = 20 RESULT_TIMEOUT_S = 15 def read_pcm(path): with wave.open(path, "rb") as wav: rate = wav.getframerate() if wav.getnchannels() != 1 or wav.getsampwidth() != 2 or rate not in SAMPLE_RATES: sys.exit("The WAV file must be 16-bit mono PCM at 8, 16 or 48 kHz") return wav.readframes(wav.getnframes()), rate async def stream_audio(ws, pcm, rate): """Send the audio at real-time pace, as a live call would, then commit.""" frame_bytes = rate * 2 * FRAME_MS // 1000 # 20 ms of 16-bit mono audio try: for offset in range(0, len(pcm), frame_bytes): await ws.send(pcm[offset:offset + frame_bytes]) await asyncio.sleep(FRAME_MS / 1000) await ws.send(json.dumps({"type": "commit_utterance"})) except websockets.ConnectionClosed: pass # The server ended the request early; its result explains why. async def resolve(pcm, rate): headers = {"X-API-Key": os.environ["SWIFTCOMPLETE_API_KEY"]} async with websockets.connect(URL, additional_headers=headers) as ws: await ws.send(json.dumps({ "type": "start_utterance", "idempotency_key": str(uuid.uuid4()), "audio_format": {"encoding": "pcm_s16le", "sample_rate_hz": rate}, })) sender = asyncio.create_task(stream_audio(ws, pcm, rate)) try: async for message in ws: event = json.loads(message) if event["type"] == "transcript_interim": print("Heard so far:", event["transcript"]) elif event["type"] == "transcript_final": print("Heard:", event["transcript"]) else: return event # The one result; the server then closes the connection. finally: sender.cancel() return None def describe(result): if result is None: return "The connection closed without a result" if result["type"] == "validation_completed": response = result["response"] address = (response.get("address") or {}).get("singleLine") return f"{response['validation']['result']}: {address}" if result["type"] == "not_resolved": return "No address identified; ask the caller again" detail = "" if "retry_after_ms" in result: detail += f", retry after {result['retry_after_ms']} ms" if "error_ref" in result: detail += f", ref {result['error_ref']}" if "description" in result: detail += f": {result['description']}" return f"Error: {result['code']}{detail} (turn {result['turn_id']})" def refusal(error): """Why the connection was refused, with Swiftcomplete's description when it gave one.""" try: description = json.loads(error.response.body)["description"] except (TypeError, ValueError, KeyError): description = None status = error.response.status_code return f"Connection refused: HTTP {status}" + (f": {description}" if description else "") def main(): pcm, rate = read_pcm(sys.argv[1]) audio_seconds = len(pcm) / (rate * 2) try: result = asyncio.run(asyncio.wait_for(resolve(pcm, rate), audio_seconds + RESULT_TIMEOUT_S)) except websockets.InvalidStatus as error: sys.exit(refusal(error)) except asyncio.TimeoutError: sys.exit("No result in time") print(describe(result)) if __name__ == "__main__": main()
Node.js
// Resolve a spoken UK address from a WAV file with Swiftcomplete. // // Usage (Node.js 18 or later): // npm install ws // SWIFTCOMPLETE_API_KEY=your-key node resolve-address.mjs address.wav import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import { setTimeout as sleep } from "node:timers/promises"; import WebSocket from "ws"; const URL = "wss://voice.swiftcomplete.com/v1/address/stream"; const SAMPLE_RATES = [8000, 16000, 48000]; const FRAME_MS = 20; const RESULT_TIMEOUT_MS = 15000; function readPcm(path) { const wav = readFileSync(path); if (wav.toString("ascii", 0, 4) !== "RIFF" || wav.toString("ascii", 8, 12) !== "WAVE") { throw new Error("Not a WAV file"); } let format; for (let offset = 12; offset + 8 <= wav.length; ) { const id = wav.toString("ascii", offset, offset + 4); const size = wav.readUInt32LE(offset + 4); const body = wav.subarray(offset + 8, offset + 8 + size); if (id === "fmt ") { format = { encoding: body.readUInt16LE(0), channels: body.readUInt16LE(2), sampleRate: body.readUInt32LE(4), bits: body.readUInt16LE(14), }; } else if (id === "data") { const ok = format?.encoding === 1 && format.channels === 1 && SAMPLE_RATES.includes(format.sampleRate) && format.bits === 16; if (!ok) throw new Error("The WAV file must be 16-bit mono PCM at 8, 16 or 48 kHz"); return { pcm: body, rate: format.sampleRate }; } offset += 8 + size + (size % 2); } throw new Error("The WAV file has no audio data"); } function describe(result) { if (!result) return "The connection closed without a result"; if (result.type === "validation_completed") { const { validation, address } = result.response; return `${validation.result}: ${address?.singleLine}`; } if (result.type === "not_resolved") return "No address identified; ask the caller again"; const retry = result.retry_after_ms ? `, retry after ${result.retry_after_ms} ms` : ""; const ref = result.error_ref ? `, ref ${result.error_ref}` : ""; const why = result.description ? `: ${result.description}` : ""; return `Error: ${result.code}${retry}${ref}${why} (turn ${result.turn_id})`; } const { pcm, rate } = readPcm(process.argv[2]); const frameBytes = (rate * 2 * FRAME_MS) / 1000; // 20 ms of 16-bit mono audio const audioMs = (pcm.length / (rate * 2)) * 1000; const ws = new WebSocket(URL, { headers: { "X-API-Key": process.env.SWIFTCOMPLETE_API_KEY } }); const timer = setTimeout(() => ws.terminate(), audioMs + RESULT_TIMEOUT_MS); let result; ws.on("open", async () => { ws.send(JSON.stringify({ type: "start_utterance", idempotency_key: randomUUID(), audio_format: { encoding: "pcm_s16le", sample_rate_hz: rate }, })); // Send the audio at real-time pace, as a live call would, then commit. for (let offset = 0; offset < pcm.length; offset += frameBytes) { if (ws.readyState !== WebSocket.OPEN) return; // The server ended the request early. ws.send(pcm.subarray(offset, offset + frameBytes)); await sleep(FRAME_MS); } if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "commit_utterance" })); }); ws.on("message", (data, isBinary) => { if (isBinary) return; const event = JSON.parse(data.toString()); if (event.type === "transcript_interim") console.log("Heard so far:", event.transcript); else if (event.type === "transcript_final") console.log("Heard:", event.transcript); else result = event; // The one result; the server then closes the connection. }); // A refused connection: the HTTP status, with Swiftcomplete's description when it gave one. ws.on("unexpected-response", (_request, response) => { let body = ""; response.on("data", (chunk) => { body += chunk; }); response.on("end", () => { let description; try { description = JSON.parse(body).description; } catch { /* no JSON body */ } console.error(`Connection refused: HTTP ${response.statusCode}${description ? `: ${description}` : ""}`); clearTimeout(timer); process.exit(1); }); }); ws.on("error", (error) => console.error(error.message)); ws.on("close", () => { clearTimeout(timer); console.log(describe(result)); });
- Ask for the whole address at once, including the postcode, for example: “Please tell
me your full address, including the postcode.” - Open the connection early, while your agent is still asking the question, so it’s
ready when the caller starts speaking. - Commit promptly once the caller has finished.
- Read the address back to the caller to confirm it, especially for a
suggestion. - If the address isn’t resolved, ask again on a new connection, for example asking for
the postcode first. - Keep your API key on your server.
401when connecting: check that the key is copied exactly and is sent in the
X-API-Keyheader.403when connecting: read thedescription. Check that your account has credits
and that the key has no URL or IP address restrictions.api_key_rejected: your key stopped being accepted during the request. Read the
descriptionand check your account.protocol_errorstraight afterstart_utterance: check that you sent
audio_format.invalid_audio: check thataudio_formatmatches the audio you send (step 4). Raw
audio must have no WAV header.- Frequent
not_resolvedorno_speech: check that the audio is the caller’s voice at
the right sample rate, isn’t clipped or very quiet, and contains the full address.
If you need help, contact us at
support@swiftcomplete.com and include the turn_id, and
the error_ref if you received one.
How your voice agent might benefit from Swiftcomplete
- Contact centres: capture a caller’s address without spelling it out letter by letter.
- Voice AI agents: give your agent a validated address it can confirm and act on.
- IVR systems: take addresses in self-service flows without an agent.
- Deliveries and bookings: collect a delivery or appointment address over the phone.
- Insurance and claims: record the address of a property or incident accurately.
- Utilities and telecoms: check addresses during sign-up, moves and engineer bookings.