# Voice calls (Shared · `Src\Common\Voice`)

**Provider:** Twilio Programmable Voice · **Interface:** `Src\Common\Voice\VoiceCaller` · **Bound in:** [`AppServiceProvider`](/app/Providers/AppServiceProvider.php)

## What it does

Places one outbound **phone call** that plays a pre-rendered audio file (an AI-voiced script)
when the person answers. It is the voice twin of [`SmsSender`](/docs/modules_handbook/shared/sms/readMe.md):
every feature depends on the **`VoiceCaller` interface**, never on Twilio, so the telephony
provider is swappable by changing one binding.

Today it carries the **funnel VOICE automations** (the Automation tab's `Voice call` medium —
see [Funnel Automation](/docs/modules_handbook/manage/events/funnel-automation/readMe.md)).
It is deliberately generic — any future feature (payment-overdue calls, appointment
confirmations) can use it.

**WhatsApp does NOT run through Twilio.** The Twilio number (`+60360431529`) is used for PSTN
calls only; WhatsApp stays on the Meta Cloud API / Bridge channels. (Investigated 2026-08-07:
Twilio-as-WhatsApp-BSP cannot bypass Meta's per-recipient marketing cap — error 131049 is
identical on every BSP — so there was no reason to route WhatsApp through it.)

## How it works

- **Contract** — [`VoiceCaller`](/src/Common/Voice/VoiceCaller.php):
  `call(string $to, string $twimlUrl, ?string $statusCallbackUrl): ?string` returns the
  provider call id (Twilio Call SID) or **null** on failure — it **never throws** (a failed
  call is logged, mirroring `SmsSender`). `isConfigured(): bool` gates UI affordances.
- **Implementation** — [`TwilioVoiceCaller`](/src/Common/Voice/TwilioVoiceCaller.php): one
  `POST /2010-04-01/Accounts/{sid}/Calls.json` with basic auth (thin `Http::` client, no SDK),
  a 25s ring timeout, and **`MachineDetection=Enable`** — the human/machine verdict rides the
  TwiML request as `AnsweredBy`, and the TwiML endpoint **hangs up on a machine** so voicemail
  never eats a full playback's minutes. A Twilio refusal logs the masked number + Twilio's own
  `code`/`message` (e.g. geo-permissions off, unverified trial destination).
- **Local dev logs instead of calling** — [`LogVoiceCaller`](/src/Common/Voice/LogVoiceCaller.php)
  is bound in `local` (unless `VOICE_REAL_IN_LOCAL=true`), mirroring `LogSmsSender`: every real
  call is billed and a home IP cannot receive Twilio's webhooks anyway. It reports itself
  configured, so the Automation tab's Voice affordances are testable without credentials.
- **Credentials are DB-first** — the **Voice calls — Twilio** card on **Messages → Settings →
  Delivery APIs** (`messaging_credentials`, `TYPE_VOICE`; fields: Account SID, API key SID +
  secret, Auth token, Caller ID number — validated as **E.164 with the leading `+`**,
  because a local-shape number saves fine and then fails at Twilio on the first real call).
  [`MessagingCredentialProvider`](/docs/modules_handbook/shared/messaging-credentials/readMe.md)
  pushes saved values into `services.twilio.*` at boot; `.env`
  (`TWILIO_ACCOUNT_SID` / `TWILIO_API_KEY_SID` / `TWILIO_API_KEY_SECRET` / `TWILIO_AUTH_TOKEN` /
  `TWILIO_FROM_NUMBER`) is the fallback.
  ⚠️ Long-running Horizon workers pick a credential change up on the next restart/deploy.
- **Authenticate with an API KEY, not the auth token.** `TwilioVoiceCaller::basicAuth()` accepts
  either shape and the **key wins whenever both are stored**, so adding a key is all it takes to
  stop using the account-wide secret. The distinction is not cosmetic: an API key (`SK…` + secret)
  is scoped and independently revocable, while the auth token IS the account — anything holding it
  can buy numbers, place calls and read every recording, and rotating it breaks **every other
  integration on the same account at the same moment**. That is a live concern here, not a
  hypothetical: this Twilio account is shared (a second number routes to GoHighLevel), so one
  credential per consumer is what keeps a rotation from taking an unrelated system down.
  The key SID is validated as `SK` + 32 hex at the form, so a pasted Account SID is refused there
  instead of 401-ing on the first billed call.
- **`isConfigured()` is the ONE availability predicate, and it gates BOTH ends.** The funnel
  Automation tab hides its "+ Voice call" item without it (`voiceReady` prop), the Delivery APIs
  card shows the same verdict as a badge, and — because the UI is never the gate (GUIDELINES
  §10) — `FunnelAutomation\StoreRequest` **refuses to save a voice rule** when it is false. A
  rule saved with no way to place a call would sit there looking live and record
  `Skipped(missing_config)` on every registration.
- **The call's instructions are public-but-signed webhooks** —
  [`Webhooks\TwilioVoiceController`](/app/Http/Controllers/Webhooks/TwilioVoiceController.php)
  behind Laravel's `signed` middleware (the caller mints `temporarySignedRoute` URLs per call;
  Twilio echoes them back exactly, so an unguessable URL is the auth):
  - `GET|POST /webhooks/twilio/voice/play/{media}` — TwiML: `<Play>` the stored audio via a
    short-lived signed GCS URL (XML-escaped — a signed URL carries `&`), or `<Hangup/>` when
    `AnsweredBy` says a machine picked up.
  - `POST /webhooks/twilio/voice/status/{send}` — Twilio's one completion callback: folds the
    final status into the funnel ledger row (`no-answer` / `busy` / `failed` → FAILED with a
    readable reason; voicemail-hangup → FAILED `voicemail`; a human → stays SENT with
    `call_duration` in `meta`). A callback whose `CallSid` differs from the row's stored
    `provider_call_id` is ignored — a replay can never rewrite a different call's outcome.

### Reference usage — the funnel voice automation (reference implementation)

The canonical consumer is [`SendFunnelVoiceCall`](/app/Jobs/Automation/SendFunnelVoiceCall.php).
Copy this shape:

1. **Render the script**, then **synthesise + store ONCE** (`GeminiTtsClient::synthesize` →
   `MediaService::store`, collection `funnel_voice`) with a deterministic cache name hashing
   the rendered text + voice — a 500-person session runs ONE synthesis, not 500.
2. **Guard the clock**: calls only between **09:00–21:00** (`app.user_timezone`) — the job
   records `Skipped(quiet_hours)` otherwise. Any scheduled caller must keep this guard: the
   scanners run 24h and a catch-up tick at 3am must never ring a customer at 3am.
3. Mint the **signed** TwiML + status URLs, then `app(VoiceCaller::class)->call(...)` and
   record the returned SID (null = failed) — never assume the call reached a human until the
   status callback says so.
4. **Queue and pace bulk calls** (`redis-broadcast` lane) — `call()` is a synchronous HTTP
   round-trip and every placed call costs money; rate-limit any admin-triggered test endpoint
   (each test is billed).

In tests, bind fakes — never place a call or spend a synthesis:

```php
$this->app->instance(VoiceCaller::class, $fakeCaller);       // records calls, returns a fake SID
$this->app->instance(GeminiTtsClient::class, $fakeTts);      // returns fake WAV bytes
```

See [tests/Feature/Event/FunnelVoiceTest.php](/tests/Feature/Event/FunnelVoiceTest.php) for both fakes.

## Configuration

`config/services.php` → `twilio`, from `.env` (DB overrides win):

| Key | Env | Notes |
| --- | --- | --- |
| `account_sid` | `TWILIO_ACCOUNT_SID` | The Twilio account — always needed (it addresses the account in the URL path), even when authenticating with an API key |
| `api_key_sid` | `TWILIO_API_KEY_SID` | **Preferred auth.** `SK` + 32 hex. Scoped + independently revocable |
| `api_key_secret` | `TWILIO_API_KEY_SECRET` | **Never commit.** Shown once at creation — Twilio cannot re-display it |
| `auth_token` | `TWILIO_AUTH_TOKEN` | **Never commit.** Fallback only — the account's master secret; rotating it breaks every other integration sharing the account |
| `from_number` | `TWILIO_FROM_NUMBER` | Caller ID (E.164, e.g. `+60360431529` — a Twilio-owned MY number, so recipients see a local number) |
| `ca_bundle` | `TWILIO_CA_BUNDLE` | Optional; falls back to `storage/certs/cacert.pem` (the Laragon cURL-77 fix) |

**Speech synthesis** rides the existing Gemini TTS setup (`services.gemini.api_key` +
`GEMINI_TTS_MODEL`; voice catalogue in `config/video.voices` — shared with the AI Video module).

## Honest limits (v1)

- **Playback, not conversation.** The call plays a pre-rendered script (+ hangs up). There is
  no speech recognition, no barge-in, no dialogue — a conversational AI agent is a different,
  much larger project.
- **No retry ladder.** A no-answer is recorded and left alone; redial policy is a future
  decision, not an accident.
- **Cost is real**: Twilio bills per minute per answered call, and the number's monthly rental
  must stay paid — if the Twilio account lapses, the number (and its WhatsApp registration)
  is lost.

## Related files

- [src/Common/Voice/VoiceCaller.php](/src/Common/Voice/VoiceCaller.php) — the interface.
- [src/Common/Voice/TwilioVoiceCaller.php](/src/Common/Voice/TwilioVoiceCaller.php) · [LogVoiceCaller.php](/src/Common/Voice/LogVoiceCaller.php) — the implementations.
- [app/Http/Controllers/Webhooks/TwilioVoiceController.php](/app/Http/Controllers/Webhooks/TwilioVoiceController.php) + [routes/main.php](/routes/main.php) (`webhooks.twilio.voice.*`, `signed`).
- [app/Jobs/Automation/SendFunnelVoiceCall.php](/app/Jobs/Automation/SendFunnelVoiceCall.php) — the reference consumer.
- [app/Helpers/GeminiTtsClient.php](/app/Helpers/GeminiTtsClient.php) — the TTS transport (owned by AI Video).
- [tests/Feature/Event/FunnelVoiceTest.php](/tests/Feature/Event/FunnelVoiceTest.php) — job ladder + webhooks + fakes, incl. a voice rule being refused when telephony is unconfigured.
- [app/Http/Controllers/Manage/Integrations/MessagingSettingsController.php](/app/Http/Controllers/Manage/Integrations/MessagingSettingsController.php) + [tests/Feature/Integrations/MessagingSettingsTest.php](/tests/Feature/Integrations/MessagingSettingsTest.php) — the Delivery APIs card: save, encrypt, mask, and reach `services.twilio.*`.
