Diagnostic Twilio
a recording row says absent and there is no media behind it
Someone in compliance asks for the call from the fourteenth. The recording is in the list, with a SID, a date and a call SID beside it. The media URL returns 404. Its status is absent, its error_code is populated, and it has been sitting there like that for six weeks because nothing in the system treats a row that exists as a recording that does not.
Page GET /2010-04-01/Accounts/{AccountSid}/Recordings.json?DateCreated>=YYYY-MM-DD&PageSize=1000 and keep every row whose status is absent. On those rows error_code is populated, which it is not on a healthy recording.
Then read source on each one. It tells you which mechanism asked for the recording — DialVerb, RecordVerb, Conference, OutboundAPI, StartCallRecordingAPI, Trunking — and therefore where the recording status callback that would have alerted you in real time is supposed to be attached. Cross-reference each call_sid against a sweep of GET https://monitor.twilio.com/v1/Alerts over the same window, at both log levels.
The problem in plain words
Twilio creates the Recording resource when recording is requested, not when media lands. So the row is real, immediate, and complete enough to satisfy anything that checks for existence: your database stores the SID, your UI renders a player, your retention job counts it. If the media is never produced, the row's status becomes absent and everything downstream carries on referencing a recording that is not there.
The call itself is untouched by this. It rang, it connected, it lasted eleven minutes, it completed. Nothing in the call logs, the call events or the connect metrics is different from the calls whose audio you do have. That is the whole difficulty: the failure is in a sibling resource that only reveals itself if you go and read its status field, and nobody reads a status field on a resource they have already stored the SID of.
Why it happens
Existence and availability are different questions and only one gets asked. The recording SID comes back, gets persisted, and is treated as a fact from then on. Re-reading the resource later to find out whether it ever became audio is an extra call that no ordinary code path has a reason to make.
error_code is only populated on the absent rows. Which is correct and is also why it is easy to miss: a scan that reads error_code across all recordings sees it empty everywhere and concludes the field is unused. It only means anything on the subset you have to filter for first.
The discovery is always months late and always external. Recordings are written for a purpose that is not today's: a dispute, an audit, a quality review. Nobody plays yesterday's recordings, so the gap is found by the one party who cannot be told to check again next week.
The callback that would have told you is optional and per-verb. A recording status callback is an attribute on the <Dial> or <Record> verb, or a parameter on the create request, depending on which mechanism started the recording. It is not a single account-level setting, so getting it onto every path is four or five separate small changes and one of them is always missed.
The fix, as a flow
The script filters on status first and reads error_code afterwards, because that field is populated only on the absent rows. Scanning for it across everything makes it look unused.
How to fix it
Page the recordings over the window you care about
GET /2010-04-01/Accounts/{AccountSid}/Recordings.json?DateCreated>=YYYY-MM-DD&PageSize=1000, following next_page_uri, which on this API is a path rather than an absolute URL. Pick the window from your retention policy, not from the incident: the question is how many of the recordings you believe you hold are real.
Filter on status, then read error_code
absent is the finding. error_code is populated on those rows and on essentially none of the others, so read it after filtering rather than scanning for it — a scan across everything makes the field look unused and the problem look absent in the other sense.
Group by source to find which mechanism is failing
source names what asked for the recording. A gap concentrated in DialVerb is a different investigation from one spread evenly across RecordVerb, Conference and OutboundAPI: the first points at one TwiML path, the second at something account-wide. This grouping is what turns a list of missing files into one thing to fix.
Treat a completed recording of zero duration as a second finding
A row with status completed and duration of 0 is media that exists and contains nothing. It will play, it will pass any check for presence, and it holds no audio. It is not the same failure as absent and it belongs in the same report, because the person asking for the call cannot use either.
Cross-reference the call SIDs, then wire up the callback
Sweep GET https://monitor.twilio.com/v1/Alerts at both LogLevel=error and LogLevel=warning over the same window and match on resource_sid: an alert against the same call tells you what else was going wrong at the time. Then add recordingStatusCallback to the verb, or RecordingStatusCallback to the create request, so the next one alerts on the day rather than at the audit.
How to check it worked
Re-run over a window that starts after the callback is in place. Absent rows should be zero, and any that do appear should now also be in your own alerting.
python3 twilio_absent_recordings_audit.py --days 30
# 1420 recording(s), 0 absent, 0 empty
The full code
One paginated GET over the recordings, plus two alert sweeps when you ask for the cross-reference. An API Key with read access is enough. The classification is a pure function over a single Recording, paired with a small table that maps source to where the status callback for that mechanism is configured — because the finding is only half useful without the sentence telling you which of five places to put the callback that would have caught it.
"""Report Twilio recordings whose media was never produced.
Read only. GET requests and nothing else: give this an API Key with read access
rather than the account auth token. The repair is printed, never performed,
because this script holds a credential to an account that can place calls and
spend money.
"""
import argparse
import datetime as dt
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("twilio_absent_recordings_audit")
HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"
MONITOR = "https://monitor.twilio.com/v1"
# Where the status callback lives for each mechanism that can start a recording.
# The finding is only half useful without this sentence: knowing a recording is
# missing does not tell you which of five places the callback belongs in.
SOURCES = {
"DialVerb": "recordingStatusCallback is an attribute on the Dial verb",
"RecordVerb": "recordingStatusCallback is an attribute on the Record verb",
"Conference": "recordingStatusCallback is an attribute on the Conference noun",
"OutboundAPI": "RecordingStatusCallback is a parameter on the call create request",
"StartCallRecordingAPI":
"RecordingStatusCallback is a parameter on the recording create request",
"StartConferenceRecordingAPI":
"RecordingStatusCallback is a parameter on the recording create request",
"Trunking": "recording is configured on the trunk itself, so there is no "
"per-call attribute to add here",
}
def source_meaning(source):
"""Where the recording status callback is configured for this source. Pure."""
key = str(source or "").strip()
return SOURCES.get(
key, "the source is not one this script recognises, so check how the "
"recording was started before deciding where the callback goes")
def seconds(value):
"""A recording duration as an int. It arrives as a string and can be absent."""
try:
return int(str(value or "0").strip() or 0)
except ValueError:
return 0
def verdict(recording):
"""Classify one Recording. Pure, so the rules can be tested without a
network.
Returns (state, detail). The two states worth acting on are absent, where no
media was ever produced, and empty, where media exists and holds no audio.
Both fail the person who asks for the call, and only the first has an
error_code to explain itself.
"""
status = str(recording.get("status") or "").strip().lower()
source = str(recording.get("source") or "").strip()
code = str(recording.get("error_code") or "").strip()
if status == "absent":
why = ("error_code %s" % code) if code else \
"no error_code, which is unusual on an absent row"
return ("absent",
"%s asked for this recording and no media was produced (%s). "
"The call itself completed normally, so nothing else about it "
"looks wrong. %s."
% (source or "An unnamed source", why, source_meaning(source)))
if status in ("processing", "in-progress"):
return ("in-flight",
"status is %s: the media is still being written, so this is a "
"verdict about a moment rather than a fault." % status)
if status == "deleted":
return ("deleted",
"the media has been deleted. The row survives deletion, so a "
"check that only looks for the recording's existence will keep "
"reporting this call as recorded.")
if status == "completed":
if seconds(recording.get("duration")) <= 0:
return ("empty",
"completed with a duration of zero: the media exists, it "
"will play, and there is no audio in it. It passes every "
"check for presence and fails the only one that matters.")
return ("stored", "completed with %ds of media."
% seconds(recording.get("duration")))
return ("other", "status is %s, which this script has no rule for."
% (status or "empty"))
def get(session, url, **params):
r = session.get(url, params=params, timeout=30)
if r.status_code in (401, 403):
raise SystemExit("%d from Twilio: check TWILIO_ACCOUNT_SID and that the "
"API key belongs to that account with read access"
% r.status_code)
r.raise_for_status()
return r.json()
def list_recordings(session, account, since, limit):
"""Page the Recordings listing. next_page_uri here is a path, not a URL."""
url = "%s/Accounts/%s/Recordings.json" % (BASE, account)
params = {"DateCreated>=": since, "PageSize": 1000}
out = []
while url and len(out) < limit:
page = get(session, url, **params)
out.extend(page.get("recordings", []))
nxt = page.get("next_page_uri")
url, params = (HOST + nxt) if nxt else None, {}
return out[:limit]
def list_alerts(session, since, limit, log_level):
"""Page the Monitor alerts at one log level. next_page_url is absolute."""
url = MONITOR + "/Alerts"
params = {"LogLevel": log_level, "StartDate": since, "PageSize": 1000}
out = []
while url and len(out) < limit:
page = get(session, url, **params)
out.extend(page.get("alerts", []))
url = (page.get("meta") or {}).get("next_page_url")
params = {}
return out[:limit]
def alerts_by_call(session, since, limit):
"""Alert error codes keyed by the call sid they were raised against.
Both log levels, because several voice failures are logged at warning and an
error-only sweep will report that nothing else was wrong with these calls.
"""
out = {}
for level in ("error", "warning"):
for a in list_alerts(session, since, limit, level):
sid = str(a.get("resource_sid") or "")
if sid.startswith("CA"):
out.setdefault(sid, set()).add(str(a.get("error_code") or "?"))
return out
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=30, help="window to audit")
ap.add_argument("--max-recordings", type=int, default=20000,
help="stop after this many recordings")
ap.add_argument("--with-alerts", action="store_true",
help="also sweep Alerts and match on the call sid")
args = ap.parse_args()
account = os.environ.get("TWILIO_ACCOUNT_SID")
key = os.environ.get("TWILIO_API_KEY")
secret = os.environ.get("TWILIO_API_SECRET")
if not (account and key and secret):
log.error("set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET "
"(an API Key with read access, not the auth token)")
return 2
session = requests.Session()
session.auth = (key, secret)
since = (dt.date.today() - dt.timedelta(days=args.days)).isoformat()
recordings = list_recordings(session, account, since, args.max_recordings)
if not recordings:
log.info("no recordings in the last %d day(s)", args.days)
return 0
alerts = {}
if args.with_alerts:
# Alerts are retained 30 days, so a longer recording window is only
# partially covered by this cross-reference.
alerts = alerts_by_call(session, since, 10000)
absent = 0
empty = 0
by_source = {}
for rec in recordings:
state, detail = verdict(rec)
if state not in ("absent", "empty"):
continue
if state == "absent":
absent += 1
src = str(rec.get("source") or "unknown")
by_source[src] = by_source.get(src, 0) + 1
else:
empty += 1
log.warning("%-9s %s %s", state, rec.get("sid"), detail)
call_sid = str(rec.get("call_sid") or "")
if call_sid in alerts:
log.warning(" same call raised alert(s): %s",
", ".join(sorted(alerts[call_sid])))
log.info("%d recording(s), %d absent, %d empty",
len(recordings), absent, empty)
if not (absent or empty):
return 0
if by_source:
log.warning("absent by source: %s",
", ".join("%s=%d" % kv for kv in sorted(by_source.items())))
log.warning(" repair: attach a recording status callback where the "
"recording is started, so the next failure alerts on the day "
"instead of at the audit")
log.warning(" repair: reconcile your own recording table against status, "
"not against the presence of a recording sid")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report Twilio recordings whose media was never produced.
*
* Read only. GET requests and nothing else: give this an API Key with read
* access rather than the account auth token. The repair is printed, never
* performed.
*/
const HOST = 'https://api.twilio.com';
const BASE = `${HOST}/2010-04-01`;
const MONITOR = 'https://monitor.twilio.com/v1';
// Where the status callback lives for each mechanism that can start a
// recording. Knowing a recording is missing does not tell you which of five
// places the callback belongs in.
const SOURCES = {
DialVerb: 'recordingStatusCallback is an attribute on the Dial verb',
RecordVerb: 'recordingStatusCallback is an attribute on the Record verb',
Conference: 'recordingStatusCallback is an attribute on the Conference noun',
OutboundAPI: 'RecordingStatusCallback is a parameter on the call create request',
StartCallRecordingAPI:
'RecordingStatusCallback is a parameter on the recording create request',
StartConferenceRecordingAPI:
'RecordingStatusCallback is a parameter on the recording create request',
Trunking: 'recording is configured on the trunk itself, so there is no ' +
'per-call attribute to add here',
};
/** Where the recording status callback is configured for this source. Pure. */
export function sourceMeaning(source) {
const key = String(source ?? '').trim();
return SOURCES[key] ??
'the source is not one this script recognises, so check how the recording ' +
'was started before deciding where the callback goes';
}
/** A recording duration as a number. It arrives as a string. */
export function seconds(value) {
const n = Number.parseInt(String(value ?? '0').trim(), 10);
return Number.isFinite(n) ? n : 0;
}
/**
* Classify one Recording. Pure. Returns [state, detail]. The two states worth
* acting on are absent, where no media was ever produced, and empty, where
* media exists and holds no audio.
*/
export function verdict(recording) {
const status = String(recording.status ?? '').trim().toLowerCase();
const source = String(recording.source ?? '').trim();
const code = String(recording.error_code ?? '').trim();
if (status === 'absent') {
const why = code ? `error_code ${code}`
: 'no error_code, which is unusual on an absent row';
return ['absent',
`${source || 'An unnamed source'} asked for this recording and no media ` +
`was produced (${why}). The call itself completed normally, so nothing ` +
`else about it looks wrong. ${sourceMeaning(source)}.`];
}
if (status === 'processing' || status === 'in-progress') {
return ['in-flight',
`status is ${status}: the media is still being written, so this is a ` +
'verdict about a moment rather than a fault.'];
}
if (status === 'deleted') {
return ['deleted',
'the media has been deleted. The row survives deletion, so a check that ' +
'only looks for the recording\'s existence will keep reporting this call ' +
'as recorded.'];
}
if (status === 'completed') {
if (seconds(recording.duration) <= 0) {
return ['empty',
'completed with a duration of zero: the media exists, it will play, ' +
'and there is no audio in it. It passes every check for presence and ' +
'fails the only one that matters.'];
}
return ['stored', `completed with ${seconds(recording.duration)}s of media.`];
}
return ['other', `status is ${status || 'empty'}, which this script has no rule for.`];
}
function authHeader(key, secret) {
return `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}`;
}
async function get(auth, url, params = {}) {
const u = new URL(url);
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
const res = await fetch(u, { headers: { Authorization: auth } });
if (res.status === 401 || res.status === 403) {
throw new Error(`${res.status} from Twilio: check TWILIO_ACCOUNT_SID and ` +
'that the API key belongs to that account with read access');
}
if (!res.ok) throw new Error(`${res.status} from ${u.pathname}`);
return res.json();
}
export async function listRecordings(auth, account, since, limit = 20000) {
let url = `${BASE}/Accounts/${account}/Recordings.json`;
let params = { 'DateCreated>=': since, PageSize: 1000 };
const out = [];
while (url && out.length < limit) {
const page = await get(auth, url, params);
out.push(...(page.recordings ?? []));
url = page.next_page_uri ? HOST + page.next_page_uri : null;
params = {};
}
return out.slice(0, limit);
}
async function listAlerts(auth, since, limit, logLevel) {
let url = `${MONITOR}/Alerts`;
let params = { LogLevel: logLevel, StartDate: since, PageSize: 1000 };
const out = [];
while (url && out.length < limit) {
const page = await get(auth, url, params);
out.push(...(page.alerts ?? []));
url = page.meta?.next_page_url ?? null;
params = {};
}
return out.slice(0, limit);
}
/** Alert error codes keyed by call sid, swept at both log levels. */
export async function alertsByCall(auth, since, limit = 10000) {
const out = new Map();
for (const level of ['error', 'warning']) {
for (const a of await listAlerts(auth, since, limit, level)) {
const sid = String(a.resource_sid ?? '');
if (!sid.startsWith('CA')) continue;
if (!out.has(sid)) out.set(sid, new Set());
out.get(sid).add(String(a.error_code ?? '?'));
}
}
return out;
}
async function main() {
const account = process.env.TWILIO_ACCOUNT_SID;
const key = process.env.TWILIO_API_KEY;
const secret = process.env.TWILIO_API_SECRET;
if (!account || !key || !secret) {
console.error('set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET ' +
'(an API Key with read access, not the auth token)');
process.exitCode = 2;
return;
}
const auth = authHeader(key, secret);
const i = process.argv.indexOf('--days');
const days = i === -1 ? 30 : Number(process.argv[i + 1]);
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
const recordings = await listRecordings(auth, account, since);
if (recordings.length === 0) {
console.log(`no recordings in the last ${days} day(s)`);
return;
}
// Alerts are retained 30 days, so a longer recording window is only partially
// covered by this cross-reference.
const alerts = process.argv.includes('--with-alerts')
? await alertsByCall(auth, since)
: new Map();
let absent = 0;
let empty = 0;
const bySource = new Map();
for (const rec of recordings) {
const [state, detail] = verdict(rec);
if (state !== 'absent' && state !== 'empty') continue;
if (state === 'absent') {
absent += 1;
const src = String(rec.source ?? 'unknown');
bySource.set(src, (bySource.get(src) ?? 0) + 1);
} else {
empty += 1;
}
console.warn(`${state.padEnd(9)} ${rec.sid} ${detail}`);
const callSid = String(rec.call_sid ?? '');
if (alerts.has(callSid)) {
console.warn(` same call raised alert(s): ${[...alerts.get(callSid)].sort().join(', ')}`);
}
}
console.log(`${recordings.length} recording(s), ${absent} absent, ${empty} empty`);
if (!absent && !empty) return;
if (bySource.size) {
console.warn('absent by source: ' +
[...bySource.entries()].sort().map(([k, v]) => `${k}=${v}`).join(', '));
}
console.warn(' repair: attach a recording status callback where the recording ' +
'is started, so the next failure alerts on the day instead of at ' +
'the audit');
console.warn(' repair: reconcile your own recording table against status, not ' +
'against the presence of a recording sid');
process.exitCode = 1;
}
// Only run when invoked directly, so importing this module from the test file
// does not fire main(), fail on the missing credentials and set an exit code
// that fails the suite even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The case that earns its place is completed with a duration of zero. It is the one a reasonable implementation gets wrong, because completed reads as success and the row has media behind it — media containing no audio. The others pin the boundary between a recording that is still being written and one that never will be, which is the difference between re-running the audit in a minute and opening a ticket.
from twilio_absent_recordings_audit import source_meaning, verdict
def test_absent_row_names_the_error_code_and_the_source():
state, detail = verdict({"status": "absent", "error_code": 12400,
"source": "DialVerb"})
assert state == "absent"
assert "error_code 12400" in detail
assert "Dial verb" in detail
def test_absent_row_without_an_error_code_says_so():
state, detail = verdict({"status": "absent", "source": "RecordVerb"})
assert state == "absent"
assert "unusual" in detail
def test_completed_with_zero_duration_is_its_own_finding():
state, detail = verdict({"status": "completed", "duration": "0"})
assert state == "empty"
assert "no audio" in detail
def test_completed_with_media_is_stored():
assert verdict({"status": "completed", "duration": "671"})[0] == "stored"
def test_in_progress_is_a_moment_not_a_fault():
assert verdict({"status": "processing"})[0] == "in-flight"
def test_deleted_row_survives_the_media():
state, detail = verdict({"status": "deleted"})
assert state == "deleted"
assert "recording sid" not in detail
def test_trunking_source_has_nowhere_to_put_a_per_call_callback():
assert "trunk itself" in source_meaning("Trunking")
def test_unrecognised_source_does_not_invent_a_place_for_the_callback():
assert "not one this script recognises" in source_meaning("SomethingNew")
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { sourceMeaning, verdict } from './twilio-absent-recordings-audit.mjs';
test('absent row names the error code and the source', () => {
const [state, detail] = verdict({ status: 'absent', error_code: 12400,
source: 'DialVerb' });
assert.equal(state, 'absent');
assert.match(detail, /error_code 12400/);
assert.match(detail, /Dial verb/);
});
test('absent row without an error code says so', () => {
const [state, detail] = verdict({ status: 'absent', source: 'RecordVerb' });
assert.equal(state, 'absent');
assert.match(detail, /unusual/);
});
test('completed with zero duration is its own finding', () => {
const [state, detail] = verdict({ status: 'completed', duration: '0' });
assert.equal(state, 'empty');
assert.match(detail, /no audio/);
});
test('completed with media is stored', () => {
assert.equal(verdict({ status: 'completed', duration: '671' })[0], 'stored');
});
test('in progress is a moment not a fault', () => {
assert.equal(verdict({ status: 'processing' })[0], 'in-flight');
});
test('deleted row survives the media', () => {
assert.equal(verdict({ status: 'deleted' })[0], 'deleted');
});
test('trunking source has nowhere to put a per call callback', () => {
assert.match(sourceMeaning('Trunking'), /trunk itself/);
});
test('unrecognised source does not invent a place for the callback', () => {
assert.match(sourceMeaning('SomethingNew'), /not one this script recognises/);
});
FAQ
Does an absent recording mean the call was not recorded?
It means no media was produced or the media was lost. Recording was requested, which is why the resource exists at all, and the audio you would have played is not there. The call itself is unaffected and completed normally, which is exactly why nothing else in the system flags it.
Why is error_code empty on all my other recordings?
Because it is populated only when status is absent. A scan that reads error_code across every recording sees it blank everywhere and concludes the field is unused. Filter on status first, then read the code on what is left.
What does source tell me that status does not?
Which mechanism asked for the recording, and therefore where the status callback belongs. A gap concentrated in DialVerb is one TwiML path to fix; one spread across RecordVerb, Conference and OutboundAPI is something account-wide. It turns a list of missing files into a single thing to change.
Is a completed recording with zero duration the same problem?
No, and that is why it is a separate state. Absent means no media. Zero duration means media that exists, plays, and contains nothing. Both fail the person who asked for the call, and only the first carries an error code to explain itself, so a check written against error_code alone misses the second entirely.
Can the script delete or re-request the broken rows?
It will not. It holds a read-only key, and there is nothing to re-request anyway: the audio was never produced and cannot be recovered after the fact. What it prints is the change that stops the next one going unnoticed for six weeks.
Related field notes
- Recordings stored without encryption at rest
- Recordings billed for storage until something deletes them
- Status callbacks failing with 11200
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Recording resource — Twilio Docs
- TwiML Voice: <Record> — Twilio Docs
- TwiML Voice: <Dial> — Twilio Docs
- Alert resource (Monitor) — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.