[object Object]

← back to Butlr

Add external call-trigger API for first-party sites (NPH)

f7091440b22411efd462d375c0ee9786fcb6b714 · 2026-05-19 13:21:46 -0700 · Steve

POST /api/external/place-call lets NationalPaperHangers.com place a Butlr
call without the owner-authed wizard. Guarded by a shared-secret header
(X-Butlr-External-Secret); fail-closed when BUTLR_EXTERNAL_SECRET is unset.

Three modes — hold / bridge / ai_agent — map onto the existing addCall +
dialCall path. Calls are owned by a dedicated auto-created service user so
per-user scoping holds. allow_repeat:true is passed so different customers
can call the same installer; the 4-call/number hard cap still applies.
Honors TWILIO_DRY_RUN like every other call path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit f7091440b22411efd462d375c0ee9786fcb6b714
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue May 19 13:21:46 2026 -0700

    Add external call-trigger API for first-party sites (NPH)
    
    POST /api/external/place-call lets NationalPaperHangers.com place a Butlr
    call without the owner-authed wizard. Guarded by a shared-secret header
    (X-Butlr-External-Secret); fail-closed when BUTLR_EXTERNAL_SECRET is unset.
    
    Three modes — hold / bridge / ai_agent — map onto the existing addCall +
    dialCall path. Calls are owned by a dedicated auto-created service user so
    per-user scoping holds. allow_repeat:true is passed so different customers
    can call the same installer; the 4-call/number hard cap still applies.
    Honors TWILIO_DRY_RUN like every other call path.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 package.json                     |   2 +-
 routes/external.js               | 215 +++++++++++++++++++++++++++++++++++++++
 server.js                        |   5 +
 test/external-place-call.test.js | 190 ++++++++++++++++++++++++++++++++++
 4 files changed, 411 insertions(+), 1 deletion(-)

diff --git a/package.json b/package.json
index 3c34764..802c5ac 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
   "scripts": {
     "start": "node server.js",
     "dev": "node server.js",
-    "test": "node test/orphan-recordings.test.js && node test/admin-gate.test.js && node test/vapi-webhook.test.js && node test/dnc-check.test.js && node test/upload-watcher.test.js && node test/sms-stop.test.js && node test/password-reset.test.js",
+    "test": "node test/orphan-recordings.test.js && node test/admin-gate.test.js && node test/vapi-webhook.test.js && node test/dnc-check.test.js && node test/upload-watcher.test.js && node test/sms-stop.test.js && node test/password-reset.test.js && node test/external-place-call.test.js",
     "report": "node scripts/call-quality-report.js --remote"
   },
   "dependencies": {
diff --git a/routes/external.js b/routes/external.js
new file mode 100644
index 0000000..8ca6bc7
--- /dev/null
+++ b/routes/external.js
@@ -0,0 +1,215 @@
+// External call-trigger API — lets trusted first-party sites (currently
+// NationalPaperHangers.com) place a Butlr call without going through the
+// owner-authed 4-step wizard.
+//
+// SECURITY MODEL
+//   This router is mounted OUTSIDE the requireOwner gate. It does NOT rely
+//   on a user cookie. Instead every request must carry a shared secret in
+//   the `X-Butlr-External-Secret` header. The secret is compared in
+//   constant time against process.env.BUTLR_EXTERNAL_SECRET.
+//   If BUTLR_EXTERNAL_SECRET is unset, the endpoint is hard-disabled (503)
+//   — fail-closed, never open.
+//
+// DATA SEPARATION
+//   NPH never touches Butlr's data files and vice-versa. The only contract
+//   is this HTTP endpoint. Calls placed here are owned by a dedicated
+//   service user ("external-service") so they don't pollute a human's queue
+//   and so getCall()'s per-user scoping still holds.
+//
+// THREE MODES (must match the NPH modal)
+//   hold      — Butlr dials the business, waits through IVR/hold, calls the
+//               CUSTOMER back when a human answers. Needs customer_phone.
+//   bridge    — Butlr/Twilio bridges customer <-> business directly. Needs
+//               customer_phone. (Same call row shape; callback_phone is the
+//               customer's number, which the live flow dials to bridge.)
+//   ai_agent  — Butlr's AI calls the business, delivers a project brief,
+//               reports back. Needs `brief` + customer_phone (the SMS
+//               report-back contact).
+//
+//   customer_phone is required for every mode — hold/bridge dial it, and
+//   addCall() requires a valid callback_phone on every call row regardless.
+//
+// CALL-ONCE RULE
+//   lib/data.js addCall() HARD-blocks dialing a phone that already has a
+//   non-terminal call, UNLESS allow_repeat:true. An NPH installer will
+//   legitimately be called by many different customers, so every
+//   NPH-originated call passes allow_repeat:true. The downstream hard cap
+//   of 4 calls/number still applies (data.js MAX_CALLS_PER_PHONE) — that
+//   protects the installer regardless of opt-in. See report for rationale.
+//
+// DRY-RUN
+//   Honors TWILIO_DRY_RUN exactly like every other Butlr call path. No
+//   special-casing — if TWILIO_DRY_RUN!=='0' (the default) nothing real
+//   is dialed; the call row just advances through the simulated state
+//   machine. The JSON response echoes `dry_run` so NPH can surface it.
+
+const express = require('express');
+const crypto = require('crypto');
+const router = express.Router();
+const data = require('../lib/data');
+const users = require('../lib/users');
+
+const SECRET_HEADER = 'x-butlr-external-secret';
+const SERVICE_EMAIL = 'external-service@butlr.local';
+
+// Lazily resolve (and, first time, create) the dedicated service user that
+// owns every externally-originated call. Kept out of seedLegacyAdmin so it
+// only exists once an external integration is actually wired.
+let _serviceUserId = null;
+async function getServiceUserId() {
+  if (_serviceUserId) return _serviceUserId;
+  let u = users.findByEmail(SERVICE_EMAIL);
+  if (!u) {
+    // Unguessable random password — this account never logs in interactively.
+    const r = await users.createUser({
+      email: SERVICE_EMAIL,
+      password: crypto.randomBytes(24).toString('base64url'),
+      role: 'user',
+    });
+    if (!r.ok) throw new Error('failed to create service user: ' + r.error);
+    u = users.findByEmail(SERVICE_EMAIL);
+  }
+  _serviceUserId = u.id;
+  return _serviceUserId;
+}
+
+// Constant-time secret check. Fail-closed when the env secret is unset.
+function checkSecret(req) {
+  const expected = process.env.BUTLR_EXTERNAL_SECRET || '';
+  if (!expected) return { ok: false, code: 503, error: 'external_api_disabled' };
+  const supplied = String(req.headers[SECRET_HEADER] || '');
+  if (!supplied) return { ok: false, code: 401, error: 'missing_secret' };
+  const a = Buffer.from(expected);
+  const b = Buffer.from(supplied);
+  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
+    return { ok: false, code: 401, error: 'bad_secret' };
+  }
+  return { ok: true };
+}
+
+const MODES = new Set(['hold', 'bridge', 'ai_agent']);
+
+router.post('/api/external/place-call', express.json(), async (req, res) => {
+  const gate = checkSecret(req);
+  if (!gate.ok) return res.status(gate.code).json({ ok: false, error: gate.error });
+
+  const b = req.body || {};
+  const mode = String(b.mode || '').trim();
+  const installerPhone = String(b.installer_phone || '').trim();
+  const installerName = String(b.installer_name || 'this business').trim().slice(0, 120);
+  const customerPhone = String(b.customer_phone || '').trim();
+  const customerName = String(b.customer_name || 'A customer').trim().slice(0, 80);
+  const brief = String(b.brief || '').trim().slice(0, 1500);
+
+  const errors = [];
+  if (!MODES.has(mode)) errors.push('mode must be one of: hold, bridge, ai_agent');
+  if (!installerPhone) errors.push('installer_phone is required');
+  // customer_phone is required for ALL modes: hold + bridge dial it directly,
+  // ai_agent needs it for the SMS report-back AND because Butlr's addCall()
+  // requires a valid callback_phone on every row.
+  if (!customerPhone) {
+    errors.push('customer_phone is required (where to call you back / report to)');
+  }
+  if (mode === 'ai_agent' && brief.length < 8) {
+    errors.push('brief (8+ chars) is required for ai_agent mode');
+  }
+  if (errors.length) return res.status(400).json({ ok: false, errors });
+
+  // Build the goal string per mode. addCall() requires goal >= 8 chars.
+  let goal;
+  if (mode === 'ai_agent') {
+    goal = `AI agent call on behalf of ${customerName}. Project brief: ${brief} `
+      + `Ask about availability and a rough timeline, then end the call and report back.`;
+  } else if (mode === 'bridge') {
+    goal = `Click-to-call bridge: connect ${customerName} directly with ${installerName}. `
+      + `${brief ? 'Context: ' + brief : 'Customer wants to discuss a wallcovering install.'}`;
+  } else {
+    goal = `Hold-for-me: wait through IVR/hold at ${installerName} and call `
+      + `${customerName} back when a live person answers. `
+      + `${brief ? 'Context: ' + brief : 'Customer wants to discuss a wallcovering install.'}`;
+  }
+
+  // callback_phone is the number Butlr reaches the customer on — the
+  // call-back target for hold, the bridge leg for bridge, the SMS
+  // report-back number for ai_agent. Always the customer's phone.
+  const callbackPhone = customerPhone;
+
+  let userId;
+  try {
+    userId = await getServiceUserId();
+  } catch (e) {
+    console.error('[external] service user error:', e.message);
+    return res.status(500).json({ ok: false, error: 'service_user_unavailable' });
+  }
+
+  const callRow = {
+    category: 'other',
+    business_name: installerName,
+    business_phone: installerPhone,
+    callback_phone: callbackPhone,
+    callback_name: customerName,
+    callback_email: '',
+    goal,
+    notes: `NPH call-installer · mode=${mode} · origin=nationalpaperhangers.com`,
+    max_hold_minutes: mode === 'bridge' ? 5 : 60,
+    max_spend_cents: 500,
+    consent_recording: 'on',
+    consent_terms: 'on',
+    notify_sms: customerPhone ? 'on' : '',
+    notify_email: '',
+    // The installer is a business the customer chose to call; many different
+    // customers will legitimately call the same installer. Bypass the
+    // once-per-phone block. The 4-calls/number hard cap in data.js still
+    // applies and is NOT bypassable.
+    allow_repeat: true,
+  };
+
+  const r = data.addCall(callRow, userId);
+  if (!r.ok) {
+    // max_retries_reached (the hard cap) surfaces here — return 429 so NPH
+    // can tell the customer the installer has been called too many times.
+    const isCap = (r.errors || []).some(e => /max_retries_reached/.test(e));
+    return res.status(isCap ? 429 : 400).json({ ok: false, errors: r.errors });
+  }
+
+  // Kick the dial in-process (same as the wizard's /new/submit) so the call
+  // starts moving immediately rather than waiting for the 5s worker tick.
+  try {
+    const twilio = require('../lib/twilio');
+    twilio.dialCall(r.call).catch(e => console.error('[external-dial]', e.message));
+  } catch (e) {
+    console.error('[external-dial-spawn]', e.message);
+  }
+
+  const dryRun = process.env.TWILIO_DRY_RUN !== '0';
+  const publicUrl = process.env.PUBLIC_URL || '';
+  res.json({
+    ok: true,
+    call_id: r.call.id,
+    mode,
+    status: r.call.status,
+    dry_run: dryRun,
+    // NPH can poll this to show live status. It is owner-scoped, so NPH
+    // cannot read it without the service user's cookie — it is returned for
+    // logging/debugging and future signed-status work, not as a live link.
+    status_path: `/api/calls/${r.call.id}/status`,
+    message: dryRun
+      ? 'Call queued in DRY-RUN mode — no real phone call placed. Set TWILIO_DRY_RUN=0 on Butlr to dial for real.'
+      : 'Call queued and dialing.',
+  });
+});
+
+// Lightweight health probe so NPH can verify the integration is reachable
+// and whether real dialing is enabled. Secret-gated like the main endpoint.
+router.get('/api/external/health', (req, res) => {
+  const gate = checkSecret(req);
+  if (!gate.ok) return res.status(gate.code).json({ ok: false, error: gate.error });
+  res.json({
+    ok: true,
+    service: 'butlr-external',
+    twilio_dry_run: process.env.TWILIO_DRY_RUN !== '0',
+    modes: ['hold', 'bridge', 'ai_agent'],
+  });
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index 8820bd8..5e4530d 100644
--- a/server.js
+++ b/server.js
@@ -15,6 +15,7 @@ const vapiWebhooks = require('./routes/vapi-webhooks');
 const adminRoutes = require('./routes/admin');
 const uploadsRoutes = require('./routes/uploads');
 const placesRoutes = require('./routes/places');
+const externalRoutes = require('./routes/external');
 const { attachListenBridge } = require('./lib/listen-bridge');
 const { requireOwner, softAuth, mountLogin } = require('./lib/owner-auth');
 const users = require('./lib/users');
@@ -122,6 +123,10 @@ app.use((req, res, next) => {
 app.use('/twilio', twilioWebhooks);
 // Vapi webhooks — assistant serverUrl is https://butlr.agentabrams.com/vapi/webhook.
 app.use('/vapi', vapiWebhooks);
+// External call-trigger API — first-party sites (NationalPaperHangers.com)
+// place calls via POST /api/external/place-call. Mounted BEFORE the
+// owner-auth gate; the router enforces its own shared-secret header check.
+app.use('/', externalRoutes);
 
 // Owner-auth: /login + /logout (always public). Then hard-gate every route
 // that exposes call data so no future route can leak by accident.
diff --git a/test/external-place-call.test.js b/test/external-place-call.test.js
new file mode 100644
index 0000000..512119d
--- /dev/null
+++ b/test/external-place-call.test.js
@@ -0,0 +1,190 @@
+// Tests for routes/external.js — the first-party call-trigger API used by
+// NationalPaperHangers.com. Runs entirely in dry-run; never dials.
+//
+// Boots the real app on an ephemeral port with a known shared secret and
+// TWILIO_DRY_RUN forced ON, then exercises the secret gate, the 3 modes,
+// input validation, and the allow_repeat / 4-call-cap interaction.
+
+const assert = require('assert');
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+process.env.HFM_NO_WORKER = '1';
+process.env.HFM_NO_WATCHER = '1';
+process.env.TWILIO_DRY_RUN = '1';            // never dial for real
+process.env.BUTLR_EXTERNAL_SECRET = 'test-secret-abc123';
+process.env.PORT = '0';                       // ephemeral
+
+// Isolate the call store so we don't clobber a real data/calls.json.
+const CALLS_FILE = path.join(__dirname, '..', 'data', 'calls.json');
+const BACKUP = CALLS_FILE + '.test-backup';
+let hadFile = false;
+if (fs.existsSync(CALLS_FILE)) { hadFile = true; fs.renameSync(CALLS_FILE, BACKUP); }
+function restore() {
+  try { if (fs.existsSync(CALLS_FILE)) fs.unlinkSync(CALLS_FILE); } catch {}
+  if (hadFile && fs.existsSync(BACKUP)) fs.renameSync(BACKUP, CALLS_FILE);
+}
+
+const SECRET = 'test-secret-abc123';
+let server, base;
+
+function req(method, urlPath, { body, secret } = {}) {
+  return new Promise((resolve, reject) => {
+    const data = body ? JSON.stringify(body) : null;
+    const headers = { 'Content-Type': 'application/json' };
+    if (secret) headers['X-Butlr-External-Secret'] = secret;
+    if (data) headers['Content-Length'] = Buffer.byteLength(data);
+    const u = new URL(base + urlPath);
+    const r = http.request(
+      { hostname: u.hostname, port: u.port, path: u.pathname, method, headers },
+      (res) => {
+        let chunks = '';
+        res.on('data', (c) => (chunks += c));
+        res.on('end', () => {
+          let json = null;
+          try { json = JSON.parse(chunks); } catch {}
+          resolve({ status: res.statusCode, json });
+        });
+      }
+    );
+    r.on('error', reject);
+    if (data) r.write(data);
+    r.end();
+  });
+}
+
+// server.js auto-listens when run as main. Rather than wrangle that, mount the
+// external router on a fresh express app — it carries its own secret gate and
+// has no dependency on the rest of the server wiring.
+const express = require('express');
+const externalRoutes = require('../routes/external');
+
+(async () => {
+  let passed = 0, failed = 0;
+  const test = async (name, fn) => {
+    try { await fn(); console.log('  ✓ ' + name); passed++; }
+    catch (e) { console.log('  ✗ ' + name + '\n      ' + e.message); failed++; }
+  };
+
+  const app = express();
+  app.use('/', externalRoutes);
+  server = http.createServer(app);
+  await new Promise((res) => server.listen(0, '127.0.0.1', res));
+  base = 'http://127.0.0.1:' + server.address().port;
+
+  console.log('routes/external.js — place-call API');
+
+  await test('health requires the shared secret', async () => {
+    const r = await req('GET', '/api/external/health');
+    assert.strictEqual(r.status, 401);
+  });
+
+  await test('health passes with the correct secret', async () => {
+    const r = await req('GET', '/api/external/health', { secret: SECRET });
+    assert.strictEqual(r.status, 200);
+    assert.strictEqual(r.json.twilio_dry_run, true);
+  });
+
+  await test('place-call rejects a bad secret', async () => {
+    const r = await req('POST', '/api/external/place-call', {
+      secret: 'wrong',
+      body: { mode: 'hold', installer_phone: '+13105550000', customer_phone: '+13105551111' },
+    });
+    assert.strictEqual(r.status, 401);
+  });
+
+  await test('place-call rejects an unknown mode', async () => {
+    const r = await req('POST', '/api/external/place-call', {
+      secret: SECRET,
+      body: { mode: 'telepathy', installer_phone: '+13105550000' },
+    });
+    assert.strictEqual(r.status, 400);
+  });
+
+  await test('hold mode requires customer_phone', async () => {
+    const r = await req('POST', '/api/external/place-call', {
+      secret: SECRET,
+      body: { mode: 'hold', installer_phone: '+13105550000' },
+    });
+    assert.strictEqual(r.status, 400);
+    assert.ok(r.json.errors.some((e) => /customer_phone/.test(e)));
+  });
+
+  await test('ai_agent mode requires a brief', async () => {
+    const r = await req('POST', '/api/external/place-call', {
+      secret: SECRET,
+      body: { mode: 'ai_agent', installer_phone: '+13105550000' },
+    });
+    assert.strictEqual(r.status, 400);
+    assert.ok(r.json.errors.some((e) => /brief/.test(e)));
+  });
+
+  await test('hold mode places a dry-run call', async () => {
+    const r = await req('POST', '/api/external/place-call', {
+      secret: SECRET,
+      body: { mode: 'hold', installer_phone: '+13105552001', installer_name: 'A',
+        customer_phone: '+13105559001', customer_name: 'Cust' },
+    });
+    assert.strictEqual(r.status, 200);
+    assert.strictEqual(r.json.ok, true);
+    assert.strictEqual(r.json.dry_run, true);
+    assert.ok(r.json.call_id);
+  });
+
+  await test('bridge mode places a dry-run call', async () => {
+    const r = await req('POST', '/api/external/place-call', {
+      secret: SECRET,
+      body: { mode: 'bridge', installer_phone: '+13105552002', installer_name: 'B',
+        customer_phone: '+13105559002', customer_name: 'Cust' },
+    });
+    assert.strictEqual(r.status, 200);
+    assert.strictEqual(r.json.ok, true);
+  });
+
+  await test('ai_agent mode places a dry-run call with a brief', async () => {
+    const r = await req('POST', '/api/external/place-call', {
+      secret: SECRET,
+      body: { mode: 'ai_agent', installer_phone: '+13105552003', installer_name: 'C',
+        customer_phone: '+13105559003', customer_name: 'Cust',
+        brief: 'Grasscloth install in a dining room next month, asking availability.' },
+    });
+    assert.strictEqual(r.status, 200);
+    assert.strictEqual(r.json.ok, true);
+  });
+
+  await test('different customers can call the SAME installer (allow_repeat)', async () => {
+    const phone = '+13105557000';
+    const a = await req('POST', '/api/external/place-call', {
+      secret: SECRET,
+      body: { mode: 'hold', installer_phone: phone, installer_name: 'Rpt',
+        customer_phone: '+13105558001', customer_name: 'A' },
+    });
+    const b = await req('POST', '/api/external/place-call', {
+      secret: SECRET,
+      body: { mode: 'hold', installer_phone: phone, installer_name: 'Rpt',
+        customer_phone: '+13105558002', customer_name: 'B' },
+    });
+    assert.strictEqual(a.json.ok, true);
+    assert.strictEqual(b.json.ok, true);
+  });
+
+  await test('the 4-call-per-number hard cap still fires (429)', async () => {
+    const phone = '+13105557999';
+    let last;
+    for (let i = 0; i < 5; i++) {
+      last = await req('POST', '/api/external/place-call', {
+        secret: SECRET,
+        body: { mode: 'hold', installer_phone: phone, installer_name: 'Cap',
+          customer_phone: '+1310559900' + i, customer_name: 'C' + i },
+      });
+    }
+    assert.strictEqual(last.status, 429);
+    assert.ok(last.json.errors.some((e) => /max_retries_reached/.test(e)));
+  });
+
+  await new Promise((res) => server.close(res));
+  restore();
+  console.log(`\n${passed}/${passed + failed} passed`);
+  if (failed) process.exit(1);
+})().catch((e) => { restore(); console.error(e); process.exit(1); });

← c3e3751 wire nodemailer Purelymail SMTP transport for password-reset  ·  back to Butlr  ·  Fix µ-law decoder bias (33 → 0x84) — root cause of distorted b52ca5e →