Record and Stream to S3
Enable call recording with the Record verb, fetch the presigned URL from the API, and stream or copy the file to your own S3 bucket.
Sautikit stores call recordings in its internal S3-compatible object storage and exposes them via a short-lived presigned URL. You retrieve the URL from GET /v1/calls/{id}/recording, which returns a 302 redirect to a 15-minute presigned GET URL. From there you can stream the audio to a browser, copy it to your own S3 bucket, or transcribe it. This guide covers enabling recording, receiving the call.recording.ready event, and transferring the file to your own bucket.
events_url on the number routing to receive call.recording.ready events.There are two ways to enable recording on a call.
Place a Record action in your voice callback response. This records the caller's channel from that point forward:
{
"actions": [
{
"say": {
"text": "This call may be recorded for quality purposes.",
"voice": "alice",
"language": "en-US"
}
},
{
"record": {
"action": "https://ivr.example.com/recording-done",
"method": "POST",
"timeout": 5,
"maxLength": 3600,
"finishOnKey": "#"
}
}
]
}When recording ends, the platform POSTs to your action URL with the recording metadata.
To record both legs (caller + callee) of a connected call, set record on the Dial verb:
{
"actions": [
{
"dial": {
"number": "+254722000001",
"callerId": "+254700000001",
"timeout": 30,
"record": "record-from-answer"
}
}
]
}The record field accepts:
"record-from-answer": start recording when the callee answers"record-from-ringing": start recording immediately (includes ringing phase)When the recording file is available, Sautikit fires a call.recording.ready event to the events_url on your number routing:
{
"event_kind": "call.recording.ready",
"event_id": "01900000-0000-7000-8000-000000000010",
"payload": {
"call_id": "01900000-0000-7000-8000-000000000003",
"recording_duration_seconds": 72,
"recording_size_bytes": 576000,
"occurred_at": "2026-06-27T12:01:30Z"
}
}Set up your events handler to capture this:
// events-handler.js
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
app.post("/events", express.raw({ type: "application/json" }), async (req, res) => {
// 1. Verify the signature
const sig = req.headers["x-sautikit-signature"] ?? "";
verifySignature(req.body, sig, process.env.WEBHOOK_SECRET);
const event = JSON.parse(req.body);
if (event.event_kind === "call.recording.ready") {
const callId = event.payload.call_id;
console.log(`Recording ready for call ${callId}`);
// Fetch and transfer asynchronously; respond to webhook first
setImmediate(() => transferRecordingToS3(callId));
}
res.sendStatus(200);
});
function verifySignature(rawBody, sigHeader, secret) {
const parts = Object.fromEntries(sigHeader.split(",").map(p => p.split("=", 2)));
const ts = parts["t"]; const v1 = parts["v1"];
if (!ts || !v1) throw new Error("missing t or v1");
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) throw new Error("stale timestamp");
const expected = createHmac("sha256", secret).update(rawBody).update(".").update(ts).digest("hex");
if (!timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) throw new Error("bad sig");
}Always respond to the webhook within 10 seconds. Kick off heavy work (S3 transfer) asynchronously.
Retrieve the presigned URL for the recording:
curl -s -L \
"https://api.sautikit.com/v1/calls/$CALL_ID/recording" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" \
--output recording.wavThe API returns a 302 redirect to a 15-minute presigned S3 GET URL. The -L flag follows the redirect and downloads the file directly.
To capture the presigned URL without following the redirect:
curl -s -I \
"https://api.sautikit.com/v1/calls/$CALL_ID/recording" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | grep -i locationUse the presigned URL to stream the file directly to your S3 bucket without buffering it on your server:
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import fetch from "node-fetch";
import { Readable } from "node:stream";
const s3 = new S3Client({ region: process.env.AWS_REGION });
async function transferRecordingToS3(callId) {
const SAUTIKIT_API_KEY = process.env.SAUTIKIT_API_KEY;
const YOUR_BUCKET = process.env.S3_RECORDING_BUCKET;
// 1. Get the presigned URL from Sautikit (follow redirect)
const apiResp = await fetch(
`https://api.sautikit.com/v1/calls/${callId}/recording`,
{
headers: { Authorization: `Bearer ${SAUTIKIT_API_KEY}` },
redirect: "follow",
}
);
if (!apiResp.ok) {
throw new Error(`Failed to fetch recording: ${apiResp.status}`);
}
// 2. Stream the response body into S3
await s3.send(new PutObjectCommand({
Bucket: YOUR_BUCKET,
Key: `recordings/${callId}.wav`,
Body: Readable.from(apiResp.body),
ContentType: "audio/wav",
Metadata: { "x-sautikit-call-id": callId },
}));
console.log(`Recording for ${callId} transferred to s3://${YOUR_BUCKET}/recordings/${callId}.wav`);
}import os
import httpx
import boto3
s3 = boto3.client("s3", region_name=os.environ["AWS_REGION"])
def transfer_recording_to_s3(call_id: str) -> None:
api_key = os.environ["SAUTIKIT_API_KEY"]
bucket = os.environ["S3_RECORDING_BUCKET"]
# 1. Fetch the recording (follow redirect)
with httpx.stream(
"GET",
f"https://api.sautikit.com/v1/calls/{call_id}/recording",
headers={"Authorization": f"Bearer {api_key}"},
follow_redirects=True,
) as resp:
resp.raise_for_status()
# 2. Upload to S3 with multipart streaming
s3.upload_fileobj(
resp.stream,
bucket,
f"recordings/{call_id}.wav",
ExtraArgs={
"ContentType": "audio/wav",
"Metadata": {"x-sautikit-call-id": call_id},
},
)
print(f"Recording {call_id} transferred to s3://{bucket}/recordings/{call_id}.wav")| HTTP status | Error code | Meaning |
|---|---|---|
202 | (no error code; body contains {"status":"pending","retry_after_seconds":30}) | Recording is still being processed; retry in a few seconds |
410 | calls.recording_expired | Recording exceeded the retention period and has been deleted |
404 | calls.recording_not_found | No recording for this call |
Build a simple retry loop for 202:
async function fetchRecordingWithRetry(callId, maxAttempts = 5) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const resp = await fetch(
`https://api.sautikit.com/v1/calls/${callId}/recording`,
{
headers: { Authorization: `Bearer ${process.env.SAUTIKIT_API_KEY}` },
redirect: "manual",
}
);
if (resp.status === 302) {
return resp.headers.get("location"); // presigned URL
}
if (resp.status === 202) {
console.log(`Recording not ready yet (attempt ${attempt}/${maxAttempts}), waiting...`);
await new Promise(r => setTimeout(r, attempt * 2000)); // 2s, 4s, 6s...
continue;
}
const body = await resp.json();
throw new Error(`recording fetch failed: ${body?.error?.code}`);
}
throw new Error("recording not ready after max attempts");
}In production, prefer using the call.recording.ready event rather than polling; the event fires only when the file is confirmed available.
After a recorded call completes:
# Check the call has a recording
curl -s "https://api.sautikit.com/v1/calls/$CALL_ID" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | jq '{status, duration_seconds, has_recording}'
# Download the file directly
curl -s -L \
"https://api.sautikit.com/v1/calls/$CALL_ID/recording" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" \
-o /tmp/test-recording.wav
# Check the file was created
ls -lh /tmp/test-recording.wav
file /tmp/test-recording.wav