Skip to navigation

Transcribe calls

Turn a live call into text while it happens
View as MarkdownOpen in Claude

Live transcription turns each side of a call into text while the call happens, and posts every utterance to a webhook you run, so your app can show a transcript, search it, or act on what the caller says. Start by transcribing a call between your phone and your number into a small server that prints each utterance, then read the events in full, tune the options, summarize the conversation with AI, stop transcribing, and transcribe a whole call after it ends.

Prepare for call transcription

This guide places and answers calls the way Make and receive calls does. Have these values ready:

  • Your Space URL, such as <YOUR_SPACE>.signalwire.com.
  • Your Project ID and an API token with the Voice permission.
  • A phone number in your Space, and a phone you can call it from and answer.
  • A machine that can run a small server, and a tunnel such as ngrok that gives it a public HTTPS URL.
Trial projects only call and receive calls from verified numbers

A trial project only places calls to and receives calls from numbers you’ve purchased or verified, and can’t call internationally. Prepare for your first call shows how to verify the phone you’ll test with.

Transcribing a call can require consent

Transcription captures what people say on the call, and many jurisdictions require you to tell callers before you record or transcribe them. Check the rules that apply to your callers before you transcribe a real conversation.

Replace these values in the samples you run:

ValueReplace with
<YOUR_SPACE>Your Space’s subdomain in <YOUR_SPACE>.signalwire.com
<YOUR_PROJECT_ID>Your Project ID
<YOUR_API_TOKEN>Your API token with the Voice permission, used only in server code
<YOUR_PUBLIC_HOST>The public hostname of your server’s tunnel
<YOUR_BASIC_AUTH_PASSWORD>A password you choose for the SWML your server serves
<YOUR_TRANSCRIPT_WEBHOOK_URL>Your server’s transcript webhook, https://<YOUR_PUBLIC_HOST>/transcripts
<YOUR_TRANSCRIPT_STATUS_URL>Your server’s post-call transcript webhook, https://<YOUR_PUBLIC_HOST>/transcript-status
<YOUR_CALLER_ID>Your SignalWire phone number, or a verified caller ID, in E.164 format, when Relay places the call
<YOUR_DESTINATION>The phone you’ll answer, in E.164 format, when Relay places the call
<YOUR_CALL_ID>The ID of a live call, when you send a command over REST

How live transcription works

The SWML live_transcribe method and the calling.live_transcribe command, sent over Relay or REST, all take the same action: start a session, summarize it, or stop it. Once a session starts, SignalWire recognizes speech on each side of the call you list in direction and sends HTTP POST requests to the webhook URL you set on start:

  • With live_events set to true, one request for each utterance as soon as it’s transcribed.
  • When you send summarize, one request with an AI summary of the conversation so far.
  • When the session ends, because you stopped it or the call ended, one request with the whole conversation, totals for the session, and an AI summary if you asked for one.

All three requests arrive at the same URL, so your server tells them apart by channel_data.content_disposition, as Read transcript events on your webhook shows.

SignalWire has a second, simpler method, transcribe. It produces no text during the call. When the call ends, it posts the whole call’s text to a status_url as a single block. Choose by when you need the text:

You needUse
Text while the call is happening, one utterance at a timelive_transcribe
Who said what, as remote-caller and local-caller turnslive_transcribe
An AI summary of the conversationlive_transcribe
Text in any language other than U.S. Englishlive_transcribe
The call’s text after it ends, and nothing during ittranscribe

The rest of this guide uses live_transcribe until Transcribe the whole call after it ends.

Transcribe a call in real time

Every sample in this guide sends transcript events to one small server that prints them. Run it first, then start transcription from the product your app uses.

Transcribe a call in real time via SWML

When someone calls your number, SignalWire fetches the call’s SWML from your server and runs it. The server below serves that document and receives the transcript events, so one tunnel covers both.

1

Run the transcription server

This server answers calls with a SWML document that starts live transcription on both sides of the call, asks the caller where they’re headed, and keeps the call open for twenty seconds while they talk. It prints each utterance that arrives at /transcripts, and the transcript that arrives at /transcript-status, which the post-call section uses later. The document is served only to requests that carry the username signalwire and the password you set.

# Install: python -m pip install signalwire-sdk==3.4.1 fastapi "uvicorn[standard]"
# Save as transcribe_app.py and run: python transcribe_app.py
import uvicorn
from fastapi import FastAPI, Request
from signalwire import SWMLBuilder, SWMLService
service = SWMLService(
name="transcribe-call",
basic_auth=("signalwire", "<YOUR_BASIC_AUTH_PASSWORD>"),
)
swml = SWMLBuilder(service)
swml.answer().live_transcribe(action={
"start": {
"lang": "en-US",
"direction": ["remote-caller", "local-caller"],
"webhook": "<YOUR_TRANSCRIPT_WEBHOOK_URL>",
"live_events": True,
}
})
swml.say("Thanks for calling Bayview Taxi. Where would you like to go?").sleep(20000).say(
"Thanks. A driver is on the way. Goodbye."
)
app = FastAPI()
# SignalWire fetches the call's SWML from the server's root URL.
app.include_router(service.as_router())
# Live transcription events arrive at /transcripts.
@app.post("/transcripts")
async def transcripts(request: Request):
event = await request.json()
kind = event["channel_data"]["content_disposition"]
if kind == "transcribe.utterance":
utterance = event["utterance"]
print(f"{utterance['role']}: {utterance['content']}")
elif kind == "transcribe.live_summarize":
print(f"Summary so far: {event['conversation_summary']}")
elif kind == "transcribe.conversation_log":
print(f"Transcription ended after {len(event['conversation_log'])} utterances")
if "conversation_summary" in event:
print(f"Summary: {event['conversation_summary']}")
return {}
# Post-call transcripts arrive at /transcript-status.
@app.post("/transcript-status")
async def transcript_status(request: Request):
event = await request.json()
# Skip the calling.call.transcribe state events.
if event["event_type"].startswith("calling.transcript."):
print(f"{event['event_type']}: {event['params'].get('text', '')}")
return {}
uvicorn.run(app, host="0.0.0.0", port=8080)

Start a tunnel to port 8080, such as ngrok http 8080, and use its hostname as <YOUR_PUBLIC_HOST> in the webhook URL before you start the server. Check that the document is reachable through the tunnel:

curl -u "signalwire:<YOUR_BASIC_AUTH_PASSWORD>" "https://<YOUR_PUBLIC_HOST>/"

The response is the SWML document, starting with {"version": "1.0.0". A 401 means the password doesn’t match the one in your server.

2

Point your phone number at the server

Create a SWML Script that fetches the document from your server:

  1. Open My Resources, select + Add, then Script, then SWML Script.
  2. Name it Transcribe call and leave Used For set to Calling.
  3. Under Handle Calls Using, choose External URL and enter https://signalwire:<YOUR_BASIC_AUTH_PASSWORD>@<YOUR_PUBLIC_HOST>/ in Primary Script URL.
  4. Select Create.

Then assign the script to your phone number:

Open Phone Numbers, select the number, and select Edit Settings. Under Inbound Call Settings, select Assign Resource, choose the Resource, and save. To assign from the Resource instead, open its Addresses & Phone Numbers tab, select + Add, then Phone Number, and pick a number you own.

A phone number's Edit page in the Dashboard showing the Assign Resource button under Inbound Call Settings

Assigning a Resource under a phone number's Inbound Call Settings

To do both in code, see Answer a call via SWML.

3

Call your number and speak

Call your SignalWire number from your phone and answer the question. The server prints each utterance as it’s transcribed, with the side of the call that spoke:

local-caller: Thanks for calling Bayview Taxi. Where would you like to go?
remote-caller: To the airport, please.

remote-caller is you, and local-caller is the prompt SignalWire played. After twenty seconds you hear the goodbye, the call ends, and the server prints Transcription ended after 3 utterances with your own count. If it doesn’t work, see Troubleshoot call transcription.

The same document in YAML and JSON, to paste into a hosted SWML Script instead:

version: 1.0.0
sections:
main:
- answer: {}
- live_transcribe:
action:
start:
lang: en-US
direction:
- remote-caller
- local-caller
webhook: <YOUR_TRANSCRIPT_WEBHOOK_URL>
live_events: true
- play:
url: say:Thanks for calling Bayview Taxi. Where would you like to go?
- sleep: 20000
- play:
url: say:Thanks. A driver is on the way. Goodbye.

Transcribe a call in real time via WebSocket (Relay)

Send the live transcribe command to an answered call, passing the same action the SWML method takes. Transcript events still go to your webhook, so keep the transcription server and its tunnel running. Call setup follows Place a call via WebSocket (Relay).

1

Run the call program

This program dials your phone, starts transcription, asks where you’re headed, and hangs up after twenty seconds.

# Install: python -m pip install signalwire-sdk==3.4.1
# Save as transcribe_call_relay.py and run: python transcribe_call_relay.py
import asyncio
from signalwire.relay import RelayClient
client = RelayClient(
project="<YOUR_PROJECT_ID>",
token="<YOUR_API_TOKEN>",
contexts=["default"],
)
async def main():
async with client:
call = await client.dial(
devices=[[{
"type": "phone",
"params": {
"from_number": "<YOUR_CALLER_ID>",
"to_number": "<YOUR_DESTINATION>",
"timeout": 30,
},
}]],
)
await call.live_transcribe({
"start": {
"lang": "en-US",
"direction": ["remote-caller", "local-caller"],
"webhook": "<YOUR_TRANSCRIPT_WEBHOOK_URL>",
"live_events": True,
}
})
await call.play([{
"type": "tts",
"params": {"text": "Hi, this is Bayview Taxi. Where would you like to go?"},
}])
await asyncio.sleep(20)
async def hang_up_after_playback(_event):
if call.state != "ended":
await call.hangup()
await call.play(
[{"type": "tts", "params": {"text": "Thanks. A driver is on the way. Goodbye."}}],
on_completed=hang_up_after_playback,
)
await call.wait_for_ended()
asyncio.run(main())

The Server SDKs send the action keys as written, so they stay in snake_case in every language.

2

Answer and speak

Answer your phone and say where you’re headed. The transcription server prints a local-caller line for the prompt and a remote-caller line for your answer. After twenty seconds you hear the goodbye, the call ends, and the server prints Transcription ended after with the number of utterances. If it doesn’t work, see Troubleshoot call transcription.

Start live transcription on a call in progress via REST

When a process that doesn’t hold the call needs to transcribe it, send the calling.live_transcribe command with the answered call’s ID and a start action. The call ID is the id from the dial response, or the call_id delivered to your SWML endpoint or webhook. The Server SDKs wrap the command:

# Install: python -m pip install signalwire-sdk==3.4.1
# Save as transcribe_rest.py and run: python transcribe_rest.py
from signalwire.rest import RestClient
client = RestClient(
project="<YOUR_PROJECT_ID>",
token="<YOUR_API_TOKEN>",
host="<YOUR_SPACE>.signalwire.com",
)
client.calling.live_transcribe(
"<YOUR_CALL_ID>",
action={
"start": {
"lang": "en-US",
"direction": ["remote-caller", "local-caller"],
"webhook": "<YOUR_TRANSCRIPT_WEBHOOK_URL>",
"live_events": True,
}
},
)
print("Transcription started")

The cURL request sends the same command to Call commands. To try it, run the program while a call that isn’t already being transcribed is up, such as one from Make and receive calls. The transcription server starts printing utterances as soon as the command succeeds.

Read transcript events on your webhook

Each request is a JSON POST. Read channel_data.content_disposition first, then the fields for that event:

content_dispositionSentFields to read
transcribe.utteranceOnce per utterance, when live_events is trueutterance.role, utterance.content, confidence
transcribe.live_summarizeWhen you send summarizeconversation_summary, conversation_log
transcribe.conversation_logOnce, when the session endsconversation_log, metrics, and conversation_summary when ai_summary is true

utterance.role and each conversation_log entry’s role say which side of the call spoke, using the values you passed in direction. Every event also carries channel_data.call_id, so one server can follow several calls at once. Timestamps such as utterance.timestamp are Unix times in microseconds. The top-level confidence is the speech engine’s confidence in the utterance, from 0 to 1, or null when the engine didn’t report one.

An utterance event looks like this. The Live transcription utterance webhook reference lists every field:

Payload
{
"call_info": {
"call_id": "2e1e66e5-5d07-413d-9668-55542992eec0",
"content_type": "text/json",
"content_disposition": "post_data",
"conversation_type": "voice"
},
"confidence": 0.97,
"channel_data": {
"content_type": "text/json",
"content_disposition": "transcribe.utterance",
"call_id": "2e1e66e5-5d07-413d-9668-55542992eec0"
},
"utterance": {
"role": "remote-caller",
"content": "Hi, I'd like to book a ride to the airport.",
"lang": "en-US",
"tokens": 11,
"timestamp": 1777565701562391,
"utterance_id": "9b3c1f0e-7a52-4c8e-b0d4-3f6a2e1d9c57",
"char_count": 43,
"word_count": 9
}
}

The event sent when the session ends carries the whole conversation in order, with totals for each side in metrics. Its fields are in the Live transcription conversation log webhook reference:

Payload
{
"call_info": {
"call_id": "2e1e66e5-5d07-413d-9668-55542992eec0",
"content_type": "text/json",
"content_disposition": "post_data",
"conversation_type": "voice"
},
"channel_data": {
"content_type": "text/json",
"content_disposition": "transcribe.utterance",
"call_id": "2e1e66e5-5d07-413d-9668-55542992eec0"
},
"conversation_log": [
{
"role": "remote-caller",
"content": "Hi, I'd like to book a ride to the airport."
}
],
"metrics": {
"total_utterances": 14,
"remote_caller_turns": 8,
"local_caller_turns": 6,
"remote_caller_words": 96,
"local_caller_words": 71
}
}

The summary you request mid-call has the same conversation_log and conversation_summary fields, without metrics. See the Live transcription summary webhook reference.

Answer each request with a 2xx status within 10 seconds, and do slow work after you respond. SignalWire retries a request that fails to connect, times out, or gets a 5xx status, so a slow handler can receive the same utterance more than once. Use utterance.utterance_id to ignore repeats. A 4xx status isn’t retried, and redirects aren’t followed, so that event is lost.

Protect your webhooks

Requests carry an X-SignalWire-Signature header, so your server can confirm they came from SignalWire, as Verify webhook signature shows. You can also put a username and password in the webhook URL, as in https://<USERNAME>:<PASSWORD>@<YOUR_PUBLIC_HOST>/transcripts, and SignalWire sends them as HTTP Basic authentication. Both work for the post-call status_url too. If you reject unsigned requests, keep live_events set to true: with it off, the end-of-session event can arrive without a signature.

Live transcription options: language, direction, and speech engine

start takes two required values. lang is the language to transcribe, such as en-US or es-ES. direction lists the sides of the call to transcribe:

  • remote-caller is the audio SignalWire receives from the call: the person who called your number, or the person your app dialed.
  • local-caller is the audio SignalWire sends into the call, such as prompts it plays or the other party of a connect.

List both to get the whole conversation, or one side to transcribe only that party. Each side you list sends its own utterance events.

speech_engine picks the speech recognizer: deepgram, the default, or google. When the recognizer splits utterances too early or too late for your callers, tune vad_silence_ms, the silence in milliseconds that ends an utterance, and vad_thresh, how loud audio must be to count as speech. The live_transcribe reference lists every option with its default. The same options work in Relay and REST.

Summarize a call with AI

Live transcription can hand the conversation to an AI model and send you a summary, either once when the session ends or whenever you ask while the call is up.

Summarize the call when transcription ends

Add ai_summary: true to start, and optionally ai_summary_prompt to say what the summary should contain. The event sent when the session ends then includes conversation_summary. Make this change to the first run’s start action. The same keys work in a start action sent over Relay or REST:

swml.answer().live_transcribe(action={
"start": {
"lang": "en-US",
"direction": ["remote-caller", "local-caller"],
"webhook": "<YOUR_TRANSCRIPT_WEBHOOK_URL>",
"live_events": True,
"ai_summary": True,
"ai_summary_prompt": "List the pickup address, the destination, and the pickup time.",
}
})

The transcription server prints the summary on a Summary: line after the call ends. If conversation_summary is missing from the event, no summary could be produced for the call.

Summarize a call in progress via SWML

Send the summarize action while transcription is running to get a summary of the conversation so far, and keep transcribing. Its prompt and webhook are optional. An empty summarize object sends the summary to the webhook from start and uses the default summary prompt, since ai_summary_prompt applies only to the summary sent when the session ends. In the first run’s document, add it after the sleep, so the summary arrives before the goodbye:

swml.say("Thanks for calling Bayview Taxi. Where would you like to go?").sleep(20000)
swml.live_transcribe(action={"summarize": {"prompt": "Summarize the ride request in one sentence."}})
swml.say("Thanks. A driver is on the way. Goodbye.")

The server prints a Summary so far: line when the summary arrives. If no summary could be produced, the event still arrives, with conversation_summary set to Error creating summary. To summarize at a moment the document doesn’t know in advance, run a section with the action through execute, or send it from Relay or REST.

Summarize a call in progress via WebSocket (Relay)

In the Relay call program, send summarize after the twenty-second wait. The REST client sends the same action to a call by ID:

await call.live_transcribe({
"summarize": {"prompt": "Summarize the ride request in one sentence."}
})

The cURL request goes to Call commands.

Stop live transcription

A session stops by itself when the call ends. Stop it earlier, for example before the caller reads out a card number, with the stop action. SignalWire then sends the event for the end of the session, with everything transcribed until that point.

Stop live transcription via SWML

Set action to the string stop:

swml.live_transcribe(action="stop")

Stop live transcription via WebSocket (Relay)

Send the stop action. SignalWire accepts it either as the string stop or as a stop key with an empty object. The Relay clients take the object form, and the REST clients take the string:

await call.live_transcribe({"stop": {}})

To start transcribing again, send a new start action. A start sent while a session is running stops that session, which sends its end-of-session event, and starts a new one.

Transcribe the whole call after it ends

When you only need the call’s text once it’s over, transcribe is simpler: there are no options to set and no events during the call. It transcribes only U.S. English (en-US), so use live_transcribe for any other language. SignalWire captures the call in the background and, after the call ends, posts the text to your status_url as a calling.transcript.completed event. Only one transcribe can run on a call at a time.

Transcribe the whole call via SWML

Add transcribe with a status_url where the transcription should start. The Server SDKs’ SWML builder has no transcribe method, and its schema has no transcribe verb, so the builder samples add the verb by name and turn schema validation off where the SDK would reject it:

from signalwire import SWMLBuilder, SWMLService
service = SWMLService(
name="transcribe-whole-call",
basic_auth=("signalwire", "<YOUR_BASIC_AUTH_PASSWORD>"),
# The schema has no transcribe verb yet, and validation can only be
# turned off for the whole service, not for one verb.
schema_validation=False,
)
swml = SWMLBuilder(service)
swml.answer()
service.add_verb("transcribe", {"status_url": "<YOUR_TRANSCRIPT_STATUS_URL>"})
swml.say("Thanks for calling Bayview Taxi. Where would you like to go?").sleep(20000).say(
"Thanks. A driver is on the way. Goodbye."
)

To try it, swap these lines into the transcription server in place of its service and live_transcribe document, and call your number again. After you hang up, the server prints calling.transcript.completed: followed by the call’s text.

transcribe sets transcribe_result to success or failed. To end it before the call does, add transcribe_stop. It takes no options and stops the transcription running on the call:

- transcribe_stop: {}

Transcribe the whole call via WebSocket (Relay)

Send the transcribe command to start the transcription. It returns an action you can stop to end the transcription early. In the Relay call program, replace the live transcription lines with these:

transcription = await call.transcribe(status_url="<YOUR_TRANSCRIPT_STATUS_URL>")
# ... later, to end it before the call does:
await transcription.stop()

The action completes when SignalWire finishes capturing the call’s audio. The text itself arrives at your status_url afterward.

Over REST, send calling.transcribe with a control_id you choose, and calling.transcribe.stop with the same control_id to end it. The REST client in the Server SDKs wraps both commands:

client.calling.transcribe(
"<YOUR_CALL_ID>",
control_id="whole-call",
status_url="<YOUR_TRANSCRIPT_STATUS_URL>",
)
# ... later, to end it before the call does:
client.calling.transcribe_stop("<YOUR_CALL_ID>", control_id="whole-call")

client is the REST client from Start live transcription on a call in progress via REST. To call the REST API directly, send these requests to Call commands:

POST
/api/calling/calls
curl -X POST https://{your_space_name}.signalwire.com/api/calling/calls \
-H "Content-Type: application/json" \
-u "<project_id>:<api_token>" \
-d '{
"command": "calling.transcribe",
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"params": {
"control_id": "transcribe-control-1",
"status_url": "https://example.com/transcribe-status"
}
}'
POST
/api/calling/calls
curl -X POST https://{your_space_name}.signalwire.com/api/calling/calls \
-H "Content-Type: application/json" \
-u "<project_id>:<api_token>" \
-d '{
"command": "calling.transcribe.stop",
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"params": {
"control_id": "transcribe-control-1"
}
}'

Receive the call’s transcript

SignalWire posts the transcript to your status_url after the call ends. event_type is calling.transcript.completed with the text in params.text, or calling.transcript.failed when the call couldn’t be transcribed. params.text is omitted when nobody spoke. The Transcript status callback reference lists every field:

Payload
{
"event_type": "calling.transcript.completed",
"timestamp": 1777565701.5623918,
"project_id": "4d0d6f16-5881-4fcc-92a4-02c51a91954d",
"space_id": "451ed9ff-e568-4222-8af9-4f9ab7428d09",
"params": {
"id": "0ec5a4da-46b9-4d2c-b724-151add8d4d08",
"call_id": "2e1e66e5-5d07-413d-9668-55542992eec0",
"segment_id": "2e1e66e5-5d07-413d-9668-55542992eec0"
}
}

Answer with a 2xx status within 5 seconds. SignalWire retries the request when your server doesn’t, so the same transcript can arrive more than once. Use params.id to ignore repeats. The request is signed like the live events, as Protect your webhooks describes.

The same status_url also receives a calling.call.transcribe event when transcription starts, with params.state set to transcribing, and another with finished when it ends. The Transcribe state callback reference lists their fields. The sample servers print only the calling.transcript.* events.

Watch the live transcript with an AI sidecar

To have an AI model follow the conversation as it’s transcribed and act on it, use ai_sidecar, with or without live_transcribe. The sidecar transcribes the call the same way, then after each turn from the customer it sends the transcript to a model with your prompt and tools. It never speaks on the call. It posts advice, transcript updates, and tool results to your webhook, so it suits coaching a human agent, flagging compliance issues, or pulling data out of the call while it happens. Its callback types include the running transcript. To build one step by step, see Coach live calls with an AI sidecar.

Alternatives: run your own speech-to-text

To use a speech recognizer of your own choice, or to process the raw audio yourself, stream the call’s audio to a WebSocket server and send it on from there. To keep the audio itself, record the call. A stereo recording keeps each side on its own channel for later processing.

Troubleshoot call transcription

Find the symptom you see:

  • The call plays normally, but your server prints nothing. SignalWire couldn’t reach your webhook. Check that the URL begins with https:// and ends with /transcripts, and that the tunnel is running and points at port 8080.
  • The server prints the end of the session, but no utterances. live_events isn’t true on start. Without it, the transcript arrives only when the session ends.
  • You see utterances from only one side. direction lists one side. Add remote-caller and local-caller to transcribe both.
  • You hear an error or silence when you call your number. SignalWire couldn’t fetch your SWML. Rerun the curl check with the exact URL in the SWML Script, and confirm that the server and the tunnel are still running. A 401 means the password in the URL doesn’t match the server’s.
  • You hear your number’s old behavior. The number still points at another Resource. Open it under Phone Numbers and check Inbound Call Settings.
  • A Relay or REST start command is rejected. Check that start has both lang and direction.
  • No calling.transcript.completed event arrives. The event comes after the call ends, not during it. Check that status_url ends with /transcript-status, and that no other transcribe was already running on the call.

Next steps