Skip to content
SIP.IO
DocsStart free

Call Recording

SIP.IO can record calls under a policy you control, ship the audio off the node, transcode it, and serve it back through an audited API. Every recording is one row in recording; every view, play, download, and delete of that recording is logged to recording_access_log, so there’s always a compliance trail (HIPAA/SOC2-style access auditing).

Whether a call is recorded is decided by recording_rule, a policy table, not a per-call flag:

CREATE TABLE recording_rule (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES account(id) ON DELETE CASCADE,
scope_kind TEXT NOT NULL CHECK(scope_kind IN ('account','user','queue','did')),
scope_id TEXT,
direction TEXT NOT NULL CHECK(direction IN ('inbound_ext','outbound_ext','inbound_int','outbound_int','queue')),
mode TEXT NOT NULL DEFAULT 'off' CHECK(mode IN ('off','on','on_demand')),
stereo INTEGER NOT NULL DEFAULT 0 CHECK(stereo IN (0,1)),
retention_days INTEGER
);
  • Scope precedence: a matching user, queue, or did rule wins over an account-wide rule.
  • direction: inbound_ext / outbound_ext (PSTN in/out), inbound_int / outbound_int (internal extension calls), or queue.
  • mode: on arms recording; off (default) doesn’t. on_demand is a recognized value in the schema but isn’t wired to any trigger yet, so treat it as reserved.

At route time, the brain resolves shouldRecord(direction) for the call and stamps the routing directive with record: 1. That directive flows to the SIP signaling layer, which tells the media relay to arm recording (record-call=yes) for that call. This is armed on DID inbound, internal/extension calls, and outbound PSTN legs, anywhere a route directive is built.

The pipeline: record → spool → ingest → transcode → serve

Section titled “The pipeline: record → spool → ingest → transcode → serve”
  1. The media relay arms capture (record-call=yes), using recording-method=pcap to write a mixed pcap into the node’s local spool (/var/spool/the media relay/pcaps).
  2. A node-local spooler client (its own systemd service, separate from the general node agent) drains the spool (idle-guarded), strips the hex tag to recover the SIP Call-ID, POSTs the raw pcap bytes to the brain, then moves the file to done/ or failed/.
  3. POST /recording/ingest?callId=<SIP Call-ID>&node=<id> (node-IP gated) correlates the call via call_cdr (account_id, from_num, to_num, start_ts), streams the request body straight into object storage at raw/<recId>.pcap, and inserts a recording row with state='converting'. Idempotent per call_id: a re-shipped pcap for the same call returns the existing recId.
  4. A transcode worker (a WASM-based edge runtime, shared with voicemail transcoding) converts raw/<recId>.pcap to opus/<recId>.opus. Triggered two ways: a prompt POST from the ingest step (needs MEDIA_CONVERT_URL + MEDIA_CONVERT_TOKEN configured), or a backstop cron sweep that picks up anything still sitting in raw/.
  5. recordingSweep (every 5 minutes, plus POST /admin/recording-sweep to run it on demand) HEADs object storage for opus/<recId>.opus; once present, flips the row converting → available and stamps size_bytes. It also expires rows past retention_until (deletes the object storage object, flips state to expired).
  6. GET /v1/recordings/{id}/play or /download serves it, only once state is available.

The recording row’s state machine is: recording → spooled → shipping → converting → available → expired, with deleted and failed as terminal/error states. Only available recordings can be played or downloaded; anything else returns 409.

CREATE TABLE recording (
id TEXT PRIMARY KEY, -- rec_ prefixed ULID-style id
account_id TEXT NOT NULL,
call_id TEXT NOT NULL, -- SIP Call-ID (= call_cdr.call_id)
cdr_id TEXT,
node_id TEXT,
direction TEXT, -- inbound_ext|outbound_ext|inbound_int|outbound_int|queue
from_num TEXT,
to_num TEXT,
start_ts INTEGER,
duration_sec INTEGER,
format TEXT NOT NULL DEFAULT 'opus',
size_bytes INTEGER,
object_key TEXT, -- opus/<id>.opus (raw/ is deleted post-convert)
state TEXT NOT NULL DEFAULT 'recording',
retention_until INTEGER, -- epoch; NULL = keep forever
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);

Retention is resolved once, at ingest, and stamped onto retention_until:

  • Account default: account.recording_retention_days. NULL means the platform default of 30 days; 0 means keep forever; any other N means N days from the call’s start_ts.
  • Per-rule override: recording_rule.retention_days exists in the schema for a per-rule override (e.g. a compliance-sensitive queue keeping recordings longer than the account default), but the resolution logic that would prefer it over the account setting is not wired up yet, only the account-level setting is currently read at ingest. Treat the column as reserved until that lands.

The sweep cron expires recordings past retention_until: it deletes the object storage object and flips the row to expired. Nothing is silently kept past its window, and nothing is silently deleted before it (metadata rows are never hard-deleted by the sweep, only the audio).

recording_rule.stereo (0/1) requests the two call legs on separate left/right channels instead of a mono mix, useful for downstream transcription or QA analytics that want to separate agent and caller audio. At ingest, the brain checks whether the account has any stereo rule and, if so, passes outputChannels: 2 to the Media Convert worker for that recording; otherwise it converts to a mono mix. This is account-wide granularity today (any stereo rule on the account triggers stereo conversion for that account’s recordings), not resolved per individual call.

Mid-call pause / resume / stop / start (PCI / sensitive-data control)

Section titled “Mid-call pause / resume / stop / start (PCI / sensitive-data control)”

Some calls need part of the audio not captured, most commonly when an agent asks a caller to read out a card number. SIP.IO supports pausing recording mid-call and resuming into the same file, so the recording exists but omits the sensitive segment.

Terminal window
POST /v1/calls/{callId}/recording
{ "action": "pause" } # or "resume" | "stop" | "start"
  • callId is the SIP Call-ID for the live call.
  • Requires the recordings scope.
  • The api worker proxies this to the brain’s POST /recording-control, which verifies the call belongs to the caller’s account (via call_cdr), resolves the node handling the call, and sends a record_control op to that node’s node-agent.
  • The node-agent sends the actual command to the media relay over its ng control protocol (UDP, bencode-encoded): pausepause recording, resume/startstart recording (this the media relay build has no distinct “resume” verb, so resume and start both restart capture into the same file), stopstop recording.

This is the PCI-relevant control: an agent-app integration can call pause right before a card number is read and resume right after, so the payment detail never lands in the stored audio.

All endpoints require the recordings scope (x-api-key or a session bearer token, per API authentication). Every play, download, and delete call is streamed through the worker, never handed out as a presigned object storage URL, specifically so every access can be logged.

Method & pathPurpose
GET /v1/recordingsList recordings, keyset-paginated. Filters: state (default available), since / until (start_ts in epoch ms), search (matches from_num, to_num, or call_id), cursor, limit (default 50, max 200).
GET /v1/recordings/{id}One recording’s metadata. Logs a view access event.
GET /v1/recordings/{id}/playStreams the audio inline (content-disposition: inline) for in-browser playback. 409 if the recording isn’t available yet.
GET /v1/recordings/{id}/downloadStreams the audio as an attachment (content-disposition: attachment). Same 409 rule.
DELETE /v1/recordings/{id}Removes the audio from object storage and flips the row to deleted. The metadata row (and its access log) is kept for audit; this is a soft delete of the audio, not the record.

List response:

{
"recordings": [
{ "id": "rec_01jxk2p9q3r8s7t6u5v4w3x2y1", "call_id": "abc123@10.0.0.5",
"from_num": "+15551234567", "to_num": "1001", "start_ts": 1751389200000,
"duration_sec": 184, "size_bytes": 91234, "format": "opus", "state": "available" }
],
"next_cursor": "1751389200000:rec_01jxk2p9q3r8s7t6u5v4w3x2y1"
}

GET /v1/recordings/{id} returns the same fields plus retention_until. Playback content type is audio/ogg (Opus in an Ogg container); filename on download is {id}.opus.

Terminal window
curl 'https://api.sip.io/v1/recordings?since=1751328000000&search=%2B1555' \
-H 'x-api-key: sk_…'
curl 'https://api.sip.io/v1/recordings/rec_01jxk2p9q3r8s7t6u5v4w3x2y1/download' \
-H 'x-api-key: sk_…' -o call.opus

Every view (metadata fetch), play, download, and delete writes a row here, unconditionally, from the same code path that serves the request:

CREATE TABLE recording_access_log (
rec_id TEXT NOT NULL REFERENCES recording(id) ON DELETE CASCADE,
user_email TEXT,
action TEXT NOT NULL CHECK(action IN ('view','play','download','delete')),
ip TEXT,
ua TEXT,
ts INTEGER NOT NULL DEFAULT (unixepoch())
);

This is the audit trail that answers “who listened to this recording, when, and from where,” the standard requirement behind HIPAA/SOC2-style recording compliance. There’s no way to fetch recording audio without an access-log row being written; even a metadata-only GET is logged as view.

The recording pipeline, including stereo output and mid-call pause/resume/stop/start, is live. Not yet supported: per-rule retention override (the account-level retention setting is what’s currently applied), per-call stereo selection (stereo is account-wide via “any stereo rule”), and multi-node call-to-node resolution for /recording-control.

  • Call CDR & Export: the per-call record a recording is correlated against (call_id); recordings don’t replace the CDR, they attach audio to it.
  • API Authentication: scopes, including recordings.