← 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
M package.jsonA routes/external.jsM server.jsA test/external-place-call.test.js
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 →