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).
What triggers a recording: recording_rule
Section titled “What triggers a recording: recording_rule”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, ordidrule wins over anaccount-wide rule. direction:inbound_ext/outbound_ext(PSTN in/out),inbound_int/outbound_int(internal extension calls), orqueue.mode:onarms recording;off(default) doesn’t.on_demandis 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”- The media relay arms capture (
record-call=yes), usingrecording-method=pcapto write a mixed pcap into the node’s local spool (/var/spool/the media relay/pcaps). - 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/orfailed/. POST /recording/ingest?callId=<SIP Call-ID>&node=<id>(node-IP gated) correlates the call viacall_cdr(account_id,from_num,to_num,start_ts), streams the request body straight into object storage atraw/<recId>.pcap, and inserts arecordingrow withstate='converting'. Idempotent percall_id: a re-shipped pcap for the same call returns the existingrecId.- A transcode worker (a WASM-based edge runtime, shared with voicemail transcoding) converts
raw/<recId>.pcaptoopus/<recId>.opus. Triggered two ways: a prompt POST from the ingest step (needsMEDIA_CONVERT_URL+MEDIA_CONVERT_TOKENconfigured), or a backstop cron sweep that picks up anything still sitting inraw/. recordingSweep(every 5 minutes, plusPOST /admin/recording-sweepto run it on demand) HEADs object storage foropus/<recId>.opus; once present, flips the rowconverting → availableand stampssize_bytes. It also expires rows pastretention_until(deletes the object storage object, flips state toexpired).GET /v1/recordings/{id}/playor/downloadserves it, only oncestateisavailable.
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
Section titled “Retention”Retention is resolved once, at ingest, and stamped onto retention_until:
- Account default:
account.recording_retention_days.NULLmeans the platform default of 30 days;0means keep forever; any otherNmeans N days from the call’sstart_ts. - Per-rule override:
recording_rule.retention_daysexists 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).
Stereo recording
Section titled “Stereo recording”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.
POST /v1/calls/{callId}/recording{ "action": "pause" } # or "resume" | "stop" | "start"callIdis the SIP Call-ID for the live call.- Requires the
recordingsscope. - The api worker proxies this to the brain’s
POST /recording-control, which verifies the call belongs to the caller’s account (viacall_cdr), resolves the node handling the call, and sends arecord_controlop to that node’s node-agent. - The node-agent sends the actual command to the media relay over its
ngcontrol protocol (UDP, bencode-encoded):pause→pause recording,resume/start→start recording(this the media relay build has no distinct “resume” verb, so resume and start both restart capture into the same file),stop→stop 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.
The /v1/recordings API
Section titled “The /v1/recordings API”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 & path | Purpose |
|---|---|
GET /v1/recordings | List 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}/play | Streams the audio inline (content-disposition: inline) for in-browser playback. 409 if the recording isn’t available yet. |
GET /v1/recordings/{id}/download | Streams 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.
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.opusAccess logging: recording_access_log
Section titled “Access logging: recording_access_log”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.
Status
Section titled “Status”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.
Related
Section titled “Related”- 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.