← back to Butlr
API-Key auth preferred over Auth Token for Twilio
f463de5d8dd2b0016583e74e5a5a5c7a526a9bed · 2026-05-12 14:08:18 -0700 · Steve
twilioAuth() resolver picks the best available credential:
1) TWILIO_API_KEY_SID + TWILIO_API_KEY_SECRET (preferred, revocable)
2) TWILIO_ACCOUNT_SID + TWILIO_AUTH_TOKEN (fallback, master cred)
3) error if neither
Wires through dialCall(), sendSms(), and transcribe.downloadRecording().
log() now records which auth mode was used per call.
Files touched
M lib/transcribe.jsM lib/twilio.js
Diff
commit f463de5d8dd2b0016583e74e5a5a5c7a526a9bed
Author: Steve <steve@designerwallcoverings.com>
Date: Tue May 12 14:08:18 2026 -0700
API-Key auth preferred over Auth Token for Twilio
twilioAuth() resolver picks the best available credential:
1) TWILIO_API_KEY_SID + TWILIO_API_KEY_SECRET (preferred, revocable)
2) TWILIO_ACCOUNT_SID + TWILIO_AUTH_TOKEN (fallback, master cred)
3) error if neither
Wires through dialCall(), sendSms(), and transcribe.downloadRecording().
log() now records which auth mode was used per call.
---
lib/transcribe.js | 16 +++++++----
lib/twilio.js | 79 ++++++++++++++++++++++++++++++++++++++++---------------
2 files changed, 69 insertions(+), 26 deletions(-)
diff --git a/lib/transcribe.js b/lib/transcribe.js
index 5465320..e07a8de 100644
--- a/lib/transcribe.js
+++ b/lib/transcribe.js
@@ -20,14 +20,20 @@ fs.mkdirSync(TRANSCRIPTS_DIR, { recursive: true });
fs.mkdirSync(RECORDINGS_DIR, { recursive: true });
// Download a Twilio recording (basic auth required) to local disk.
-async function downloadRecording(recordingUrl, callId, { twilioSid, twilioToken } = {}) {
- const sid = twilioSid || process.env.TWILIO_ACCOUNT_SID;
- const tok = twilioToken || process.env.TWILIO_AUTH_TOKEN;
- if (!sid || !tok) return { ok: false, error: 'no_twilio_creds' };
+// Prefers API Key (SK.../secret) over master Auth Token, like lib/twilio.js.
+async function downloadRecording(recordingUrl, callId) {
+ const accountSid = process.env.TWILIO_ACCOUNT_SID;
+ const keySid = process.env.TWILIO_API_KEY_SID;
+ const keySecret = process.env.TWILIO_API_KEY_SECRET;
+ const authToken = process.env.TWILIO_AUTH_TOKEN;
+ let user, pass;
+ if (keySid && keySecret) { user = keySid; pass = keySecret; }
+ else if (accountSid && authToken) { user = accountSid; pass = authToken; }
+ else return { ok: false, error: 'no_twilio_creds' };
// Twilio recording URL format: append .mp3 to force MP3 download
const url = recordingUrl.endsWith('.mp3') ? recordingUrl : recordingUrl + '.mp3';
- const auth = 'Basic ' + Buffer.from(`${sid}:${tok}`).toString('base64');
+ const auth = 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
try {
const resp = await fetch(url, { headers: { 'Authorization': auth } });
if (!resp.ok) return { ok: false, error: `twilio_http_${resp.status}` };
diff --git a/lib/twilio.js b/lib/twilio.js
index 28d7b1c..6db1967 100644
--- a/lib/twilio.js
+++ b/lib/twilio.js
@@ -71,25 +71,62 @@ function pickNextStage(currentStatus) {
// ── SMS notification ────────────────────────────────────────────────
// When a call reaches a notify-worthy stage (on_hold reached, agent picked up),
// optionally send an SMS to callback_phone via Twilio. Dry-run logs.
+// ── AUTH CREDENTIAL RESOLUTION ───────────────────────────────────────
+// Prefer API Key (SK.../<secret>) over master Auth Token. Both flavors
+// use HTTP basic auth, just with different username/password mapping:
+// API Key: user=TWILIO_API_KEY_SID pass=TWILIO_API_KEY_SECRET
+// Auth Token: user=TWILIO_ACCOUNT_SID pass=TWILIO_AUTH_TOKEN
+// The Account SID still goes in the URL path either way to identify
+// which account to operate on.
+function twilioAuth() {
+ const accountSid = process.env.TWILIO_ACCOUNT_SID;
+ const keySid = process.env.TWILIO_API_KEY_SID;
+ const keySecret = process.env.TWILIO_API_KEY_SECRET;
+ const authToken = process.env.TWILIO_AUTH_TOKEN;
+ if (!accountSid) return { ok: false, error: 'no_account_sid' };
+ if (keySid && keySecret) {
+ return { ok: true, accountSid, user: keySid, pass: keySecret, mode: 'api_key' };
+ }
+ if (authToken) {
+ return { ok: true, accountSid, user: accountSid, pass: authToken, mode: 'auth_token' };
+ }
+ return { ok: false, error: 'no_credential', accountSid };
+}
+function twilioAuthHeader(auth) {
+ return 'Basic ' + Buffer.from(`${auth.user}:${auth.pass}`).toString('base64');
+}
+
async function sendSms({ to, body }) {
if (!to || !body) return { ok: false, error: 'missing to/body' };
if (DRY_RUN) {
log(`SMS dry-run to=${to} body="${body.slice(0, 60)}…"`);
return { ok: true, dry_run: true };
}
- // LIVE — would call Twilio Messages API
- // const accountSid = process.env.TWILIO_ACCOUNT_SID;
- // const authToken = process.env.TWILIO_AUTH_TOKEN;
- // const fromNum = process.env.TWILIO_PHONE_NUMBER;
- // const resp = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`, {
- // method: 'POST',
- // headers: { 'Authorization': 'Basic ' + Buffer.from(`${accountSid}:${authToken}`).toString('base64'),
- // 'Content-Type': 'application/x-www-form-urlencoded' },
- // body: new URLSearchParams({ To: to, From: fromNum, Body: body }),
- // });
- // return resp.ok ? { ok: true } : { ok: false };
- log('LIVE SMS not yet implemented');
- return { ok: false, error: 'live_sms_not_implemented' };
+ const auth = twilioAuth();
+ const fromNum = process.env.TWILIO_PHONE_NUMBER;
+ if (!auth.ok || !fromNum) {
+ log('LIVE SMS skipped — missing creds:', auth.error || 'no_phone_number');
+ return { ok: false, error: 'twilio_creds_missing' };
+ }
+ try {
+ const resp = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${auth.accountSid}/Messages.json`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': twilioAuthHeader(auth),
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams({ To: to, From: fromNum, Body: body }),
+ });
+ const json = await resp.json().catch(() => ({}));
+ if (!resp.ok) {
+ log('LIVE SMS failed:', resp.status, json && json.message);
+ return { ok: false, error: 'sms_failed', status: resp.status, detail: json };
+ }
+ return { ok: true, sid: json.sid, mode: auth.mode };
+ } catch (e) {
+ log('LIVE SMS exception:', e.message);
+ return { ok: false, error: 'sms_exception', detail: e.message };
+ }
}
// Send the right SMS for each status transition (only when notify_sms is on).
@@ -115,13 +152,13 @@ async function dialCall(call) {
}
// LIVE — actually dial via Twilio REST API. TwiML lives at
// /twilio/twiml/:call_id and always opens with the consent announcement.
- const accountSid = process.env.TWILIO_ACCOUNT_SID;
- const authToken = process.env.TWILIO_AUTH_TOKEN;
+ // Auth: prefer API Key (SK.../secret), fall back to Auth Token.
+ const auth = twilioAuth();
const fromNum = process.env.TWILIO_PHONE_NUMBER;
const publicUrl = process.env.PUBLIC_URL;
- if (!accountSid || !authToken || !fromNum || !publicUrl) {
- log('LIVE mode requested but Twilio creds missing — leaving call queued');
- return { ok: false, error: 'twilio_creds_missing' };
+ if (!auth.ok || !fromNum || !publicUrl) {
+ log('LIVE mode requested but Twilio creds missing:', auth.error || 'no_phone_number_or_public_url');
+ return { ok: false, error: 'twilio_creds_missing', detail: auth.error };
}
const body = new URLSearchParams({
To: call.business_phone,
@@ -136,10 +173,10 @@ async function dialCall(call) {
AsyncAmdStatusCallbackMethod: 'POST',
});
try {
- const resp = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Calls.json`, {
+ const resp = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${auth.accountSid}/Calls.json`, {
method: 'POST',
headers: {
- 'Authorization': 'Basic ' + Buffer.from(`${accountSid}:${authToken}`).toString('base64'),
+ 'Authorization': twilioAuthHeader(auth),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
@@ -151,7 +188,7 @@ async function dialCall(call) {
}
data.updateStatus(call.id, 'dialing');
if (data.setTwilioSid) data.setTwilioSid(call.id, json.sid);
- log(`LIVE dial placed · sid=${json.sid} · to=${call.business_phone}`);
+ log(`LIVE dial placed · sid=${json.sid} · to=${call.business_phone} · auth=${auth.mode}`);
return { ok: true, sid: json.sid };
} catch (e) {
log('LIVE dial exception:', e.message);
← 54497d0 rebrand: HoldForMe → Butlr (mass swap across 27 files: brand
·
back to Butlr
·
transcribe: whisper.cpp PRIMARY, OpenAI Whisper FALLBACK 2c7bdce →