# Recording calls

This guide covers capturing audio from a call with the `<Record>` verb, receiving the result on your server, downloading the audio and reading recording metadata through the Calls API.

## Record a voicemail message

The most common pattern prompts the caller, then records a message with `<Record>`. When the caller stops speaking, presses `#`, or the recording reaches its maximum length, Signals requests the `action` URL with the result.

<CodeTabs syncKey="lang">

```javascript title="Node.js"
const express = require("express");
const app = express();
app.use(express.urlencoded({ extended: false }));

app.post("/voice/incoming", (req, res) => {
  const oneml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Leave a message after the beep. Press the pound key when you are finished.</Say>
  <Record action="/voice/voicemail-complete" maxLength="120" finishOnKey="#"/>
  <Say>We did not receive a recording. Goodbye.</Say>
  <Hangup/>
</Response>`;

  res.type("text/xml").send(oneml);
});

app.post("/voice/voicemail-complete", (req, res) => {
  console.log("Voicemail received:", {
    recordingSid: req.body.RecordingSid,
    recordingUrl: req.body.RecordingUrl,
    durationSeconds: req.body.RecordingDuration,
  });

  const oneml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Thank you. Your message has been recorded. Goodbye.</Say>
  <Hangup/>
</Response>`;

  res.type("text/xml").send(oneml);
});

app.listen(3000);
```

```python title="Python"
from flask import Flask, Response, request

app = Flask(__name__)

@app.route("/voice/incoming", methods=["POST"])
def voice_incoming():
    oneml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Leave a message after the beep. Press the pound key when you are finished.</Say>
  <Record action="/voice/voicemail-complete" maxLength="120" finishOnKey="#"/>
  <Say>We did not receive a recording. Goodbye.</Say>
  <Hangup/>
</Response>"""

    return Response(oneml, mimetype="text/xml")

@app.route("/voice/voicemail-complete", methods=["POST"])
def voicemail_complete():
    print("Voicemail received:", {
        "recordingSid": request.form.get("RecordingSid"),
        "recordingUrl": request.form.get("RecordingUrl"),
        "durationSeconds": request.form.get("RecordingDuration"),
    })

    oneml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Thank you. Your message has been recorded. Goodbye.</Say>
  <Hangup/>
</Response>"""

    return Response(oneml, mimetype="text/xml")

if __name__ == "__main__":
    app.run(port=3000)
```

</CodeTabs>

Signals posts `RecordingSid`, `RecordingUrl` and `RecordingDuration` to `action`. See [Record](/programmable-voice/oneml/record) for every attribute, including `timeout`, `finishOnKey` and `maxLength`.

## Get notified when a recording finishes

Some recordings are not tied to a single `action`, for example when you log a whole call for quality review. Set `recordingStatusCallback` instead of, or alongside, `action` to have Signals notify a separate URL once the recording finishes:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>This call may be recorded for quality purposes.</Say>
  <Record playBeep="false" timeout="0" maxLength="3600" recordingStatusCallback="/voice/recording-status"/>
  <Hangup/>
</Response>
```

Signals requests `recordingStatusCallback` once, for the `completed` event only. The request carries these parameters:

| Parameter | Description |
|-----------|-------------|
| `AccountSid` | Your Voice Account SID. |
| `CallSid` | Unique identifier for the call. |
| `RecordingSid` | Unique identifier for the recording. |
| `RecordingUrl` | URL of the recording audio on the Calls API. |
| `RecordingStatus` | Always `completed`. |
| `RecordingDuration` | Duration of the recording, in seconds. |
| `RecordingChannels` | Number of audio channels in the recording. |
| `RecordingSource` | `RecordVerb` for `<Record>`, `DialVerb` for a recorded `<Dial>`. |

The receiver only needs to acknowledge the request. It does not control the call, because `<Record>` has already handed the call to the next verb:

<CodeTabs syncKey="lang">

```javascript title="Node.js"
const express = require("express");
const app = express();
app.use(express.urlencoded({ extended: false }));

app.post("/voice/recording-status", (req, res) => {
  console.log("Recording completed:", {
    recordingSid: req.body.RecordingSid,
    recordingUrl: req.body.RecordingUrl,
    durationSeconds: req.body.RecordingDuration,
  });
  res.sendStatus(200);
});

app.listen(3000);
```

```python title="Python"
from flask import Flask, request

app = Flask(__name__)

@app.route("/voice/recording-status", methods=["POST"])
def recording_status():
    print("Recording completed:", {
        "recordingSid": request.form.get("RecordingSid"),
        "recordingUrl": request.form.get("RecordingUrl"),
        "durationSeconds": request.form.get("RecordingDuration"),
    })
    return "", 200

if __name__ == "__main__":
    app.run(port=3000)
```

</CodeTabs>

## Download recording audio

`RecordingUrl` points at the recording's `.wav` resource on the Calls API:

```text
https://api.signals.pressone.africa/2010-04-01/Accounts/ACCOUNT_SID/Recordings/RECORDING_SID.wav
```

Downloading it requires the same HTTP Basic credentials as the rest of the Calls API. Signals checks that the recording belongs to the project that owns your Voice Account. Anyone without your auth token gets `401`.

<CodeTabs syncKey="lang">

```bash title="curl"
curl -u "ACCOUNT_SID:AUTH_TOKEN" \
  -o voicemail.wav \
  "https://api.signals.pressone.africa/2010-04-01/Accounts/ACCOUNT_SID/Recordings/REd947fbd985d13ecb4c31eca3186f20bb.wav"
```

```javascript title="Node.js"
const { writeFile } = require("node:fs/promises");

const accountSid = process.env.SIGNALS_ACCOUNT_SID;
const authToken = process.env.SIGNALS_AUTH_TOKEN;
const recordingSid = "REd947fbd985d13ecb4c31eca3186f20bb";

async function downloadRecording() {
  const response = await fetch(
    `https://api.signals.pressone.africa/2010-04-01/Accounts/${accountSid}/Recordings/${recordingSid}.wav`,
    {
      headers: {
        Authorization: `Basic ${Buffer.from(`${accountSid}:${authToken}`).toString("base64")}`,
      },
    },
  );

  if (response.status === 409) {
    console.log("Recording is still being processed; retry shortly.");
    return;
  }
  if (!response.ok) {
    throw new Error(`Download failed: ${response.status} ${await response.text()}`);
  }

  await writeFile("voicemail.wav", Buffer.from(await response.arrayBuffer()));
}

downloadRecording();
```

```python title="Python"
import os

import requests

account_sid = os.environ["SIGNALS_ACCOUNT_SID"]
auth_token = os.environ["SIGNALS_AUTH_TOKEN"]
recording_sid = "REd947fbd985d13ecb4c31eca3186f20bb"

response = requests.get(
    f"https://api.signals.pressone.africa/2010-04-01/Accounts/{account_sid}/Recordings/{recording_sid}.wav",
    auth=(account_sid, auth_token),
    timeout=60,
)

if response.status_code == 409:
    print("Recording is still being processed; retry shortly.")
else:
    response.raise_for_status()
    with open("voicemail.wav", "wb") as audio:
        audio.write(response.content)
```

</CodeTabs>

The audio is served as `audio/wav` with `Cache-Control: private, no-store`. The resource also supports:

- `HEAD`, to read `Content-Length` and `ETag` without downloading the file.
- A single `Range` header, such as `Range: bytes=0-1023`, answered with `206 Partial Content`. An unsatisfiable range returns `416`.

Possible errors:

| Status | Code | Meaning |
|--------|------|---------|
| `401` | `unauthenticated` | The username is not the path `AccountSid`, the Voice Account is not active, or the auth token is wrong. |
| `404` | `recording_not_found` | The recording does not exist, belongs to another project, or has passed its retention period. |
| `409` | `recording_processing` | Signals is still verifying the audio after the call. Retry shortly. |
| `409` | `recording_failed` | The audio could not be verified and is not available. |
| `429` | `egress_cap_reached` | Your project's recording download allowance is used up. `detail` states when it resets. |

:::tip
Do not send `RecordingUrl` with embedded credentials to a browser or mobile app. To play a recording to your own users, download it on your server and serve it from an endpoint your server authenticates.
:::

## Read recording metadata

With a `CallSid`, you can list the recordings captured during a call, and fetch any recording's metadata.

### List every recording for a call

```bash
curl -u "ACCOUNT_SID:AUTH_TOKEN" \
  "https://api.signals.pressone.africa/2010-04-01/Accounts/ACCOUNT_SID/Calls/CA5388f7a5e04d0ed71cac5cdf1ea17a53/Recordings.json"
```

<OpenPlaygroundButton
  server="https://api.signals.pressone.africa"
  url="/2010-04-01/Accounts/{AccountSid}/Calls/{CallSid}/Recordings.json"
  method="GET"
  pathParams={[
    { name: "AccountSid", defaultValue: "ACCOUNT_SID" },
    { name: "CallSid", defaultValue: "CA5388f7a5e04d0ed71cac5cdf1ea17a53" },
  ]}
>
  Try it in the playground
</OpenPlaygroundButton>

This list is not paginated, and entries carry no media URL. Build the download URL from each `sid` as shown in [Download recording audio](#download-recording-audio):

```json
{
  "recordings": [
    {
      "sid": "REd947fbd985d13ecb4c31eca3186f20bb",
      "account_sid": "AC9ce1cd445308e3ec5080c6126288aa09",
      "call_sid": "CA5388f7a5e04d0ed71cac5cdf1ea17a53",
      "duration": 42,
      "format": "wav",
      "date_created": "2026-07-02T12:00:00Z",
      "date_updated": "2026-07-02T12:00:42Z",
      "uri": "/2010-04-01/Accounts/AC9ce1cd445308e3ec5080c6126288aa09/Recordings/REd947fbd985d13ecb4c31eca3186f20bb.json"
    }
  ],
  "uri": "/2010-04-01/Accounts/AC9ce1cd445308e3ec5080c6126288aa09/Calls/CA5388f7a5e04d0ed71cac5cdf1ea17a53/Recordings.json"
}
```

### Fetch a single recording

```bash
curl -u "ACCOUNT_SID:AUTH_TOKEN" \
  "https://api.signals.pressone.africa/2010-04-01/Accounts/ACCOUNT_SID/Recordings/REd947fbd985d13ecb4c31eca3186f20bb.json"
```

A completed recording returns its `duration` and `format`:

```json
{
  "sid": "REd947fbd985d13ecb4c31eca3186f20bb",
  "account_sid": "AC9ce1cd445308e3ec5080c6126288aa09",
  "call_sid": "CA5388f7a5e04d0ed71cac5cdf1ea17a53",
  "duration": 42,
  "format": "wav",
  "date_created": "2026-07-02T12:00:00Z",
  "date_updated": "2026-07-02T12:00:42Z",
  "uri": "/2010-04-01/Accounts/AC9ce1cd445308e3ec5080c6126288aa09/Recordings/REd947fbd985d13ecb4c31eca3186f20bb.json"
}
```

If you fetch a recording before it finishes, the response omits `duration` and `format` entirely rather than returning them as `null`. An unknown `RecordingSid` returns `404`.

Signals call history and recordings are also available through the Signals API. See the [Calls and recordings API reference](/api/calls).

## Next steps

- [Record](/programmable-voice/oneml/record): every `<Record>` attribute.
- [Forwarding calls](/programmable-voice/forwarding-calls): combine `<Dial>` with a voicemail fallback when nobody answers.
- [Webhooks](/programmable-voice/webhooks): parameters and limits for every request Signals sends your server.
- [Calls API reference](/api/voice-calls): every Calls API endpoint.
