Copy recordings older than your retention window to your storage, then delete each SignalWire original after a successful copy.
recordingcompliance
The claim
The vendored REST spec lists recordings at GET /api/relay/rest/recordings, with a links object for paging and no query parameters of its own. Every variant of recording it documents carries an id, a created_at, a duration_in_seconds and a url “of the recording file”. Each variant also carries a byte_size, which is how the pass knows a copy is whole. DELETE /api/relay/rest/recordings/{id} answers 204. Retention is therefore three steps in a fixed order: find what is past the window, copy it out, delete it. The copy is a GET of url with your project credentials as basic auth. The media protection page, https://signalwire.com/docs/platform/media-protection, says protected media requires them. You reach the two endpoints as client.recordings.list and client.recordings.delete.
How it works
def export_and_delete(now=None, fetch=download):
now = now or datetime.now(timezone.utc)
moved = []
for recording in every_page(client.recordings.list):
if not expired(recording, now):
continue
suffix = pathlib.PurePosixPath(urlsplit(recording["url"]).path).suffix or ".wav"
path = EXPORT_DIR / f"{recording['id']}{suffix}"
path.write_bytes(fetch(recording["url"])) # copy first
client.recordings.delete(recording["id"]) # then, and only then, delete
moved.append({"id": recording["id"], "created_at": recording["created_at"],
"path": str(path)})
return moved
What the platform receives, per expired recording:
GET /api/relay/rest/recordings
GET /api/relay/rest/recordings?page_token=<from links.next>
GET <url> with basic auth
DELETE /api/relay/rest/recordings/<id> 204
The order is the safety property. A copy that raises stops the pass on that recording, before its DELETE, so a storage outage leaves recordings in SignalWire rather than nowhere. EXPORT_DIR stands in for your object storage; swap write_bytes for your client’s upload. download sends the credentials only to an https URL on your own space and refuses to follow a redirect. A url that pointed elsewhere would get nothing. A fetched body whose length is not the recording’s byte_size stops the pass before it writes or deletes. The script refuses RETENTION_DAYS below one at startup, because a zero window would delete everything.
Limitations
You prove the order and the requests. What url serves, and how long the platform takes to mark a recording finished, are the platform’s side.
Writing a recording somewhere else at record time is a different mechanism. The cXML <Record> reference documents storageUrl for that (https://signalwire.com/docs/compatibility-api/cxml/reference/voice/record). The bundled SWML schema’s record and record_call have no storage field, which is why this recipe works after the fact.
What to change first
Swap the two lines marked copy first and then, and only then, delete, and run the verifier. The failing-fetch case shows a DELETE for a recording that was never copied, which is the failure this recipe exists to prevent.