Record and Stream to S3
Record a Sautikit call with the record or dial voice action, fetch the 15-minute download link from the API, and copy the file to your own S3 bucket.
Record a Sautikit call with the record or dial voice action, fetch the 15-minute download link from the API, and copy the file to your own S3 bucket.
Sautikit stores each call recording and hands it out through GET /v1/calls/{call_id}/recording, which returns a download link that lasts 15 minutes. Making a recording is free. How long Sautikit keeps it depends on your storage plan, so copy anything you need long term to your own bucket.
| Storage plan | Price | Recordings kept |
|---|---|---|
| 100 MB | Free | 24 hours |
| 1 GB | KES 599 a month | 3 days |
| 4 GB | KES 1,999 a month | 14 days |
| 50 GB | KES 9,999 a month | 60 days |
voice_callback_url and an events_url. See Claim and route a number.To record both sides of a call you connect, set record on the dial action:
{
"actions": [
{ "say": { "text": "This call may be recorded for quality purposes." } },
{
"dial": {
"number": "+254720000000",
"callerId": "+254720000001",
"timeout": 30,
"record": "record-from-answer"
}
}
]
}record accepts:
"record-from-answer": start when the other side answers."record-from-ringing": start at once, ringing included.To take a message, use the record action. It records the caller from that point until they hang up, press the finish key, stay silent for timeout seconds or reach maxLength seconds:
{
"actions": [
{ "say": { "text": "Please leave a message after the tone, then press hash." } },
{
"record": {
"action": "https://ivr.example.com/recording-done",
"method": "POST",
"timeout": 5,
"maxLength": 120,
"finishOnKey": "#"
}
}
]
}When the recording ends, Sautikit POSTs to the action URL.
Sautikit posts the call's events to the events_url on your number. The last one has callSessionState: "Completed" and the call's sessionId. Use it as the trigger to fetch the recording:
// events-handler.js
import express from "express";
const app = express();
app.post("/events", express.json(), (req, res) => {
const { sessionId, callSessionState } = req.body;
// Reply first, then do the slow work.
res.sendStatus(200);
if (callSessionState === "Completed" && sessionId) {
transferRecordingToS3(sessionId).catch((err) => console.error(sessionId, err.message));
}
});
app.listen(3000);Posts to events_url are not signed. That is safe here because the handler only uses the session id to ask the API, with your key, for a recording in your own workspace.
Ask for the recording by call id or session id. With ?format=json the API returns the link and the file's details:
curl -s "https://api.sautikit.com/v1/calls/$CALL_ID/recording?format=json" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | jq .{
"url": "https://storage.sautikit.com/recordings/9d2b1f53/be36758c.wav?X-Amz-Signature=abc123",
"size_bytes": 1048576,
"duration_seconds": 45,
"mime": "audio/wav",
"expires_at": "2026-07-22T10:31:57Z"
}Without format=json the same request answers 302 and redirects to the link, so curl -L downloads the file directly:
curl -s -L "https://api.sautikit.com/v1/calls/$CALL_ID/recording" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" \
--output recording.wavThe link lasts 15 minutes. Do not store it; request a fresh one whenever you need the file.
| HTTP status | Code | Meaning |
|---|---|---|
200 or 302 | The recording is ready | |
202 | recording_not_ready | Still being saved. Wait retry_after_seconds, then ask again |
404 | recording_not_found | No recording for this call |
410 | recording_unavailable | Deleted at the end of your storage plan's retention period |
This function waits for the recording to be ready, then streams it into S3 without holding the whole file in memory.
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { Readable } from "node:stream";
const s3 = new S3Client({ region: process.env.AWS_REGION });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function getRecording(callId, maxAttempts = 6) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const resp = await fetch(
`https://api.sautikit.com/v1/calls/${callId}/recording?format=json`,
{ headers: { Authorization: `Bearer ${process.env.SAUTIKIT_API_KEY}` } }
);
const body = await resp.json();
if (resp.status === 200) return body; // { url, size_bytes, mime, ... }
if (resp.status === 202) {
await sleep((body.retry_after_seconds ?? 10) * 1000);
continue;
}
if (resp.status === 404) return null; // this call was not recorded
throw new Error(`recording fetch failed: ${resp.status}`);
}
throw new Error("recording not ready after max attempts");
}
async function transferRecordingToS3(callId) {
const recording = await getRecording(callId);
if (!recording) return;
const audio = await fetch(recording.url);
if (!audio.ok) throw new Error(`download failed: ${audio.status}`);
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_RECORDING_BUCKET,
Key: `recordings/${callId}.wav`,
Body: Readable.fromWeb(audio.body),
ContentLength: recording.size_bytes,
ContentType: recording.mime,
}));
console.log(`Recording for ${callId} copied to S3`);
}import os
import time
import boto3
import requests
s3 = boto3.client("s3", region_name=os.environ["AWS_REGION"])
API = "https://api.sautikit.com"
def get_recording(call_id: str, max_attempts: int = 6):
headers = {"Authorization": f"Bearer {os.environ['SAUTIKIT_API_KEY']}"}
for _ in range(max_attempts):
resp = requests.get(f"{API}/v1/calls/{call_id}/recording?format=json", headers=headers)
if resp.status_code == 200:
return resp.json()
if resp.status_code == 202:
time.sleep(resp.json().get("retry_after_seconds", 10))
continue
if resp.status_code == 404:
return None # this call was not recorded
resp.raise_for_status()
raise RuntimeError("recording not ready after max attempts")
def transfer_recording_to_s3(call_id: str) -> None:
recording = get_recording(call_id)
if recording is None:
return
with requests.get(recording["url"], stream=True) as audio:
audio.raise_for_status()
s3.upload_fileobj(
audio.raw,
os.environ["S3_RECORDING_BUCKET"],
f"recordings/{call_id}.wav",
ExtraArgs={"ContentType": recording["mime"]},
)After a recorded call ends:
# The call completed
curl -s "https://api.sautikit.com/v1/calls/$CALL_ID" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | jq '{status, duration_seconds}'
# The recording downloads
curl -s -L "https://api.sautikit.com/v1/calls/$CALL_ID/recording" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" \
-o test-recording.wav
ls -lh test-recording.wav
# The copy reached your bucket
aws s3 ls "s3://$S3_RECORDING_BUCKET/recordings/"record parametersrecord-from-answer and record-from-ringing