[object Object]

← 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

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 →