Skip to navigation

Record calls

Capture the conversation, then find the file
View as MarkdownOpen in Claude

SignalWire lets you make and receive calls in several ways and across different channels. You can record the full conversation, or record fragments and utterances to capture a single message. Recordings can be found in the Storage section of your Space dashboard, or you can fetch and manage them with the Recordings API.

This guide assumes you can already place and answer calls with code. If you can’t yet, your first agent is a great place to start.

Pick the right product for call recording

Recording is available across these products, but not every function is available on each.

FunctionSWMLWebSocket (Relay)RESTCall Flow Builder
Record full call in the background
Record a single message, wait until it ends
Stop a recording early
Pause and resume a recording
Record a conference
Get the recording’s URL back in your own code, without a webhook
Find and delete recordings after the call
  • SWML is the document that describes how the call should flow. It is written with the Server SDKs’ Agents namespace, but can be written by hand for simpler cases.
  • WebSocket (Relay), a Server SDK namespace, holds a live WebSocket connection to a call in progress, and allows procedural control of the call through code.
  • The REST API sends Relay commands to an ongoing call over HTTP, without writing full Relay code, and its Recordings API lets you list, fetch, and delete recordings after the call.
  • Call Flow Builder draws the same flow on a canvas, with a node per recording action and no code at all.

Prepare for call recording

Have these values ready:

Recording is regulated

Consent rules differ by jurisdiction, and some of them require you to announce the recording before it starts. Read call recording and the law before you record a real conversation.

Replace these values in the code sample you choose:

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
<YOUR_CALLER_ID>Your caller ID number
<YOUR_DESTINATION>The address you’re calling, such as a phone number in E.164 format
<YOUR_STATUS_WEBHOOK_URL>The HTTPS URL that receives recording status callbacks
<YOUR_CALL_ID>The id of the live call you’re sending a REST command to
<YOUR_RECORDING_ID>The id of a recording from the Recordings API

Record the whole call

Recording the entire call for compliance or quality assurance is a common requirement. You can start recording with call.record in the Server SDKs or the record_call method in SWML. The recording takes place in the background without interrupting the flow of the call, and stops automatically when the call ends. You can also stop it early, or pause and resume it.

Record the whole call via SWML

# Install: python -m pip install signalwire-sdk==3.4.1
# Save as record_whole_call.py and run: python record_whole_call.py
from signalwire import AgentBase
agent = AgentBase(
name="recorded-agent",
route="/agent",
record_call=True,
record_format="mp3",
record_stereo=True,
)
agent.add_language("English", "en-US", "rime.spore")
agent.prompt_add_section("Purpose", body="Answer the caller's questions.")
# record_call=True emits {"record_call": {"format": ..., "stereo": ...}} after
# answer and before the ai verb. It takes no control_id and no status_url — for
# either, or to start recording mid-call, use FunctionResult.record_call below.
if __name__ == "__main__":
agent.run()

The control_id lets you stop the recording later with stop_record_call. If you don’t set one, SignalWire generates it and saves it in the ${record_control_id} variable, which you can pass to stop_record_call the same way.

Record the whole call via WebSocket (Relay)

The cURL request sends the same calling.record command to a call that is already in progress.

# Install: python -m pip install signalwire-sdk==3.4.1
# Save as record_whole_call_relay.py and run: python record_whole_call_relay.py
from signalwire.relay import RelayClient
client = RelayClient(
project="<YOUR_PROJECT_ID>",
token="<YOUR_API_TOKEN>",
contexts=["default"],
)
@client.on_call
async def handle_call(call):
await call.answer()
playback = await call.play([{"type": "tts", "params": {"text": "This call is being recorded."}}])
await playback.wait()
action = await call.record(
audio={"direction": "both", "format": "mp3", "stereo": True}
)
await call.connect([[{"type": "phone", "params": {"to_number": "<YOUR_DESTINATION>", "from_number": "<YOUR_CALLER_ID>", "timeout": 30}}]])
event = await action.wait()
p = event.params
print(f"Recording saved: {p.get('url')} ({p.get('duration')}s, {p.get('size')} bytes)")
await call.wait_for_ended()
client.run()

The recording’s URL is available as soon as it starts: SWML saves it in the ${record_call_url} variable, and Relay reports it as url on the action’s params. If you set a status_url, SignalWire also sends status callbacks as the recording changes state.

Record the whole call via Call Flow Builder

Place a Start Call Recording node after Answer Call and route every branch of the flow to a Stop Call Recording node, so no path leaves a recording running. For a voicemail box, use the Voicemail Recording node instead.

Confirm the recording worked

You can check Storage in your dashboard for the file, or query recording details from the Recordings API and confirm its status is finished. A no_input status means the recording captured no audio, and fetching its media returns 404.

Recording options: format, stereo, and direction

There are tradeoffs associated with some of the recording options, mainly between the file size and the quality of information stored.

The stereo property should be set to true to put the caller on the left channel and the callee on the right. This ensures proper separation of the roles but requires more storage space. If set to false, both audio streams get irreversibly mixed down to mono. Stereo recording is useful for transcription and other processing later on, but mono stores more compactly, and is usually fine if the end user of the recording is a human listener.

The format property can be wav or mp3. The wav format is uncompressed and several times bigger than the mp3 format, but also stores high-quality audio. For later processing, transcription, and high-fidelity use, wav is the better choice. SWML also accepts mp4 to record video.

The direction property decides which side of the call is captured, and the two methods differ. record_call takes speak, listen, or both, and defaults to both, so a background recording covers the whole conversation. record takes speak or listen only — there is no both — and defaults to speak, the caller’s own side.

record_call and record otherwise accept the same terminators and timeouts, but the defaults are different to reflect their use cases. The record_call method is geared towards silent full call recording, while record has defaults set so the user can leave a voicemail, and stop recording either on silence or on terminator press. Details for all options are in the record_call and record references.

Record after the caller agrees

The Server SDKs can hand the decision to record to the AI agent talking to the caller (or the callee). To do so, give the agent a tool that starts the recording.

Record after the caller agrees via SWML

# Install: python -m pip install signalwire-sdk==3.4.1
# Save as recording_consent.py and run: python recording_consent.py
from signalwire import AgentBase, FunctionResult
agent = AgentBase(name="recording-agent", route="/agent")
agent.add_language("English", "en-US", "rime.spore")
agent.prompt_add_section(
"Recording policy",
body="Say 'This call may be recorded for quality purposes.' and ask the caller "
"whether they agree. Call start_recording only after they say yes. "
"If they decline, continue the call without recording.",
)
@agent.tool(name="start_recording", description="Start recording the call once the caller agrees")
def start_recording(args, raw_data):
return (
FunctionResult("Recording has started.")
.record_call(
control_id="main",
stereo=True,
format="mp3",
status_url="<YOUR_STATUS_WEBHOOK_URL>",
)
)
@agent.tool(name="stop_recording", description="Stop recording the call")
def stop_recording(args, raw_data):
return FunctionResult("Recording has stopped.").stop_record_call(control_id="main")
if __name__ == "__main__":
agent.run()

Record after the caller agrees via WebSocket (Relay)

These handlers replace the one in Record the whole call via WebSocket (Relay); the client setup above them is unchanged. The cURL request sends the same start command once your application knows the caller agreed.

# Without an AI agent, gate the recording on a keypress instead of a tool call.
@client.on_call
async def handle_call(call):
await call.answer()
action = await call.play_and_collect(
media=[{"type": "tts", "params": {
"text": "This call may be recorded for quality purposes. "
"Press 1 to agree, or 2 to continue without recording."}}],
collect={"digits": {"max": 1, "digit_timeout": 5, "terminators": "#"}},
)
event = await action.wait()
if event.params.get("result", {}).get("params", {}).get("digits", "") == "1":
await call.record(audio={"direction": "both", "format": "mp3", "stereo": True})
await call.connect([[{"type": "phone", "params": {"to_number": "<YOUR_DESTINATION>", "from_number": "<YOUR_CALLER_ID>", "timeout": 30}}]])

Stop recording the call

A background recording ends on hangup automatically. You can stop it yourself when you want the file to cover only part of the call: pass its control_id to stop_record_call in SWML, call action.stop() (Python, TypeScript) in a Relay call handler, or send calling.record.stop through the REST Calling API’s call commands against any live call.

Stop recording the call via SWML

The two tools below drop into the agent from Record after the caller agrees via SWML. The documents after them are complete on their own.

# From an AI agent's tool, by control_id.
@agent.tool(name="stop_recording", description="Stop recording the call")
def stop_recording(args, raw_data):
return FunctionResult("Recording has stopped.").stop_record_call(control_id="main")

Stop recording the call via WebSocket (Relay)

The handlers extend Record the whole call via WebSocket (Relay). The cURL request sends the same command to a call that is already recording.

@client.on_call
async def handle_call(call):
await call.answer()
action = await call.record(audio={"direction": "both", "format": "mp3"})
prompt = await call.play_and_collect(
media=[{"type": "tts", "params": {
"text": "Tell us why you are calling, then press pound."}}],
collect={"digits": {"max": 1, "terminators": "#"}},
)
await prompt.wait()
await action.stop()
await call.connect([[{"type": "phone", "params": {"to_number": "<YOUR_DESTINATION>", "from_number": "<YOUR_CALLER_ID>", "timeout": 30}}]])

Pause and resume a recording

Pausing and resuming act on a live call, so they come from a Relay call handler or the REST Calling API, not from SWML. Pausing takes an optional behavior. The default, skip, leaves the paused span out of the file entirely; silence replaces it with silence, so the recording’s timing still lines up with the call.

Pause and resume a recording via WebSocket (Relay)

Both handlers extend Record the whole call via WebSocket (Relay). The cURL requests send the same two commands over REST.

@client.on_call
async def handle_call(call):
await call.answer()
action = await call.record(audio={"direction": "both", "format": "mp3"})
await action.pause()
card_number = await take_payment(call)
await action.resume()

Reference: pause() and resume() on RecordAction (Python, TypeScript), and calling.record.pause and calling.record.resume in the REST Calling API’s call commands.

Record a single message or voicemail

A voicemail box needs the opposite behavior: record, and don’t go any further until the caller is done. The recording ends when they stop talking, press the terminator digit, or run out of time, and only then does the next instruction run.

Record a single message via SWML

# Install: python -m pip install signalwire-sdk==3.4.1
# Save as voicemail_agent.py and run: python voicemail_agent.py
from signalwire import AgentBase, FunctionResult
agent = AgentBase(name="voicemail-agent", route="/agent")
@agent.tool(name="take_voicemail", description="Record the caller's message. Call this exactly once.")
def take_voicemail(args, raw_data):
return FunctionResult("Please leave your message after the beep.").execute_swml({
"version": "1.0.0",
"sections": {
"main": [
{
"record": {
"beep": True,
"initial_timeout": 10,
"end_silence_timeout": 3,
"max_length": 120,
"terminators": "#",
"status_url": "<YOUR_STATUS_WEBHOOK_URL>",
}
}
]
},
})
if __name__ == "__main__":
agent.run()

Record a single message via WebSocket (Relay)

These handlers replace the one in Record the whole call via WebSocket (Relay).

@client.on_call
async def handle_call(call):
await call.answer()
prompt = await call.play([{"type": "tts", "params": {"text": "Leave a message after the beep."}}])
await prompt.wait() # play returns as soon as the command is accepted
action = await call.record(
audio={"beep": True, "end_silence_timeout": 3, "terminators": "#", "max_length": 120}
)
event = await action.wait(timeout=180)
# no_input is terminal too: a caller who never spoke still gets a url, but its media returns 404
if event.params.get("state") == "finished":
print(f"Voicemail saved: {event.params['url']} ({event.params.get('duration')}s)")
else:
print(f"No voicemail recorded (state={event.params.get('state') or 'call gone'})")
await call.hangup()

The SWML document plays the message straight back from record_url, and Relay gets the same URL off the finished action. Note the variable name: record sets record_url, while record_call sets record_call_url.

record captures the caller’s side only unless you ask for more. format and stereo behave the same as they do for record_call; see recording options.

Stop a single-message recording

A message recording is meant to stop on its own, and three properties decide when. end_silence_timeout ends it after that many seconds of quiet, so the caller doesn’t have to do anything at all. terminators lets them end it with a keypress the moment they’re finished. max_length caps the file however the call goes.

In SWML those are the only ways a record ends, because the script is waiting on it and there’s no later instruction to run. record defaults terminators to #. The Server SDKs give you the same recording as a RecordAction, so a handler can also stop it explicitly without waiting for the caller.

An AI agent’s turn cannot block on its own, so the way to take a voicemail is to hand the agent a tool that emits the record verb with execute_swml, as shown above. The verb holds the turn until the recording ends, so the agent cannot talk over the caller, and the conversation resumes on its own afterwards. Set initial_timeout generously — the caller needs a moment after the beep — and let end_silence_timeout close the recording.

Record a conference

A conference is recorded as a unit, and you opt in when you join it.

Record a conference via SWML

The tool below drops into the agent from Record after the caller agrees via SWML.

@agent.tool(name="join_standup", description="Put the caller into the standup conference")
def join_standup(args, raw_data):
return FunctionResult("Joining the standup.").join_conference(
name="standup",
record="record-from-start",
recording_status_callback="<YOUR_STATUS_WEBHOOK_URL>",
)

Conference callbacks use their own names — recording_status_callback and its _method, _event, and _event_type variants — rather than the status_url the other methods take. See join_conference.

Find and manage your recordings

Every recording lands in the same place, whichever surface started it. Query it with the Recordings API, receive status callbacks as it progresses, or browse it in your dashboard.

Get recordings with the Recordings API

Recordings from SWML, the Server SDKs, and Call Flow Builder can all be managed with the same API: List, Get, and Delete.

GET
/api/relay/rest/recordings
curl https://{your_space_name}.signalwire.com/api/relay/rest/recordings \
-u "<project_id>:<api_token>"
Response
{
"links": {
"self": "string",
"first": "string",
"next": "string",
"prev": "string"
},
"data": [
{
"byte_size": 10,
"created_at": "2024-01-15T09:30:00Z",
"duration_in_seconds": 2,
"error_code": "string",
"id": "d369a402-7b43-4512-8735-9d5e1f387814",
"price": 0.05,
"price_unit": "USD",
"project_id": "d369a402-7b43-4512-8735-9d5e1f387814",
"relay_conference_id": "0089cc48-4f98-4a6b-90d8-61f8a5d1b0e3",
"relay_pstn_leg_id": "string",
"status": "finished",
"stereo": false,
"track": "inbound",
"updated_at": "2024-01-15T09:30:00Z",
"url": "/api/relay/rest/recordings/d3444402-7b43-4512-8735-9d5e1f387814"
}
]
}

Each entry lists the url, duration_in_seconds and byte_size, stereo and track for how it was captured, price, and a status telling you whether the file is ready. If you’re manually listing the recordings, ensure that you’re familiar with paging in the REST APIs.

Check status before you fetch the media. A recording has a file only when status is finished. A recording that didn’t capture audio has status no_input, and fetching its media returns 404.

url is a path relative to your Space, and it points at the metadata rather than the audio. Append a format suffix to fetch the media itself — <url>.wav or <url>.mp3. Those are API routes, so they always want your API credentials:

cURL — Recordings API
curl -sL -u "<YOUR_PROJECT_ID>:<YOUR_API_TOKEN>" \
-o message.wav \
"https://<YOUR_SPACE>.signalwire.com/api/relay/rest/recordings/<YOUR_RECORDING_ID>.wav"

The status callback carries a different URL for the same audio, on files.signalwire.com. That one is the URL media protection governs: while the setting is off it is publicly fetchable by anyone who has the link, and turning the setting on is what puts it behind your credentials.

Receive recording status callbacks

You can set status_url on record or record_call, and SignalWire sends a POST request with a calling.call.record event as the recording changes state. The finished event has params.url, params.duration, and params.size. params.recording_id matches the id the Recordings API returns.

{
"event_type": "calling.call.record",
"params": {
"state": "finished",
"record": { "audio": { "format": "wav", "direction": "speak", "stereo": false } },
"url": "https://files.signalwire.com/<SPACE_ID>/<PROJECT_ID>/recordings/<RECORDING_ID>.wav",
"recording_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"control_id": "main",
"call_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"start_time": 1789651036.217461,
"end_time": 1789651055.516839,
"first_frame_time": 1789651036.219012,
"size": 298284,
"duration": 18
}
}

The other states are recording, paused, no_input, and error. The full payload is under status callbacks, and webhooks covers receiving them in general.

Status callbacks are advisory

Status callbacks are asynchronous, best-effort HTTP notifications: delivery can be delayed or fail silently — if your server is unreachable, the callback simply never arrives. Don’t gate time-critical or business-critical actions solely on receiving one. Confirm state via the REST API before acting, or use a transport with visible failure modes — see Status callback reliability.

View recordings from your dashboard

Recordings appear under Storage > Recordings in your SignalWire Space. Opening one shows its status, the call it was recorded from, and a download link for each available format.

The SignalWire Space sidebar with Storage expanded and Recordings selected. The detail pane shows a Recording with its ID, a status of Finished, the call it was recorded via, and a Files table offering Download (.wav) and Download (.mp3) links.

A finished recording in the Storage section of a SignalWire Space.

A call’s recordings are also listed on its log. Open Logs > Voice, select the call, and its details page shows every recording made during that call.

The Voice Logs page of a SignalWire Space, listing calls with their direction, ID, From and To numbers, date, and status.

Voice Logs in a SignalWire Space. Select a call to open its details page.

Secure recording media URLs

Recording URLs are public by default

Enable Media URL protection to require your project’s API credentials to access the recordings.

The recorded media URLs that SWML and webhook callbacks report are the permanent URLs of the recording. Media URLs contain randomly generated UUIDs, which makes them impractical to guess, but they aren’t access-gated by default. A dashboard setting can require project_id:api_token basic auth to access those recordings, just like the other REST endpoints.

Open the project-name menu at the top of the Dashboard and select Project Configuration. Under Settings > Media URL Protection Details, there are three independent toggles: Protect Recording Media URLs, Protect Message Media URLs, and Protect Fax Media URLs. They are all off by default.

Protection is set per project and per media type, so recordings can be protected independently of messages and faxes.

The Settings tab of a SignalWire project showing Media URL Protection Details, with separate No/Yes toggles for Protect Recording Media URLs, Protect Message Media URLs, and Protect Fax Media URLs.

Media URL Protection settings, with a separate toggle for recordings.

Turning on media protection will break any existing systems that rely on the stored media URLs, so ensure that they’re updated to pass the credentials as well.

Call recording and the law

Consent requirements and retention rules vary by jurisdiction, and some material, such as card numbers, government identifiers, and PINs, shouldn’t be recorded at all. Below are a few resources to help with compliance.

Alternatives to call recording

Audio recordings are expensive to store and difficult to search. In many cases, a transcript or a text summary is all you need. In some cases, only the answers or the result of the call need to be stored. SignalWire offers several tools for those requirements. You can use these tools as an alternative to recording, or in addition to recording.

RequirementToolResult
Structured summary of the callAn AI agent’s post-prompt summaryStructured text at your post_prompt_url after the call, from the ai method’s post_prompt property
The full text of the calltranscribeThe whole call transcribed in the background, delivered when the call ends
Text while the call is still runninglive_transcribeTranscription posted to your webhook as the call happens
Advice for a human agent mid-callai_sidecarAn AI observer on a human-to-human call, streaming events to your application without speaking

The post-prompt summary gives you the exact result of the call in the format you specify. The conversation analytics guide covers it in detail.

Next steps