All tutorials

AI Voice Address Capture: Resolve Spoken Addresses with Swiftcomplete

intermediate
1Overview

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

2How it works
  1. Your server opens a WebSocket for one address and sends start_utterance.
  2. You stream the caller’s audio as it arrives. Transcripts of what we hear come back
    while the caller speaks.
  3. When the caller has finished, you send commit_utterance.
  4. 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.

3Authentication

All requests require an API key passed in the X-Api-Key header.

Get an API key here

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.

4Prepare your audio

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.

encodingsample_rate_hzTypical source
pcm_s16le48000Browser and app microphones
pcm_s16le16000Wideband (HD) voice and many voice AI platforms
pcm_s16le8000Telephone audio decoded to 16-bit PCM
mulaw8000Telephone audio (G.711 μ-law), common in North America and Japan
alaw8000Telephone 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
Copied
5Connect

Open a WebSocket to:

wss://voice.swiftcomplete.com/v1/address/stream
Copied

Send your key in the X-API-Key header:

x-api-key: YOUR_API_KEY
Copied

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:

StatusMeaningWhat to do
401The key is missing or malformed, or Swiftcomplete doesn't recognise it.Check that the key is copied exactly and sent in X-API-Key.
403Swiftcomplete 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.
429The 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.
503The 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": []
}
Copied

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.

6Stream the address

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"
}
Copied
FieldTypeRequiredDescription
typestringYes"start_utterance"
audio_formatobjectYesThe format you'll send: encoding and sample_rate_hz, as in step 4. Without it, the request ends with protocol_error.
idempotency_keystringNoIdentifies 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" }
Copied

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"}
Copied
  • 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.
7Handle the result

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
  }
}
Copied

response is exactly what the Swiftcomplete Validate API returns for this address, so you
can handle it with the same code.

  • validation.result is match, suggestion or none.
  • validation.resolution is the level the address was matched to, for example premise
    or street.
  • address holds 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"}
Copied

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}
Copied
codeMeaningWhat to do
invalid_audioThe audio wasn't in the required format, or you committed without sending any.Check your audio against step 4.
utterance_limit_exceededA frame held more than 4 seconds of audio, or the address more than 60 seconds.Send smaller frames, and capture only the address.
no_speechWe heard no speech.Ask the caller to say their address again.
protocol_errorMessages arrived out of order or couldn't be read, for example audio before start_utterance or a second start_utterance.Fix your client.
service_busyThe service is at capacity. No work was done.Retry after retry_after_ms milliseconds.
api_key_rejectedSwiftcomplete 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_unavailableA 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."}
Copied

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.

8Retries

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.

9Limits
LimitValue
Addresses per connection1
Time to send start_utterance after connecting15 seconds
Audio per address60 seconds
Time from start_utterance to commit_utterance120 seconds
Frame size4 seconds of audio
Simultaneous connections per key4. 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.

10Coverage

The API resolves UK postal addresses spoken in English, one address per connection.

11Example code

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()
Copied

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));
});
Copied
12Best practices
  • 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.
13Troubleshooting
  • 401 when connecting: check that the key is copied exactly and is sent in the
    X-API-Key header.
  • 403 when connecting: read the description. 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
    description and check your account.
  • protocol_error straight after start_utterance: check that you sent
    audio_format.
  • invalid_audio: check that audio_format matches the audio you send (step 4). Raw
    audio must have no WAV header.
  • Frequent not_resolved or no_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.
SwiftComplete