Twilio Voice Route Map
Quick reference for local URLs (and the files that implement them). Use this when wiring Twilio Console webhooks, testing with ngrok, or tracing voice/SMS/billing flows.
Related docs
| Topic | Where |
|---|---|
| Stripe (wallet, webhooks) | docs/RINGVOO_SYSTEM_NOTES.md — wallet funding, receipt copy, admin refunds (card refunds section) |
| SMS (pricing, billing) | docs/RINGVOO_SYSTEM_NOTES.md — SMS (Twilio Messaging) |
Conventions
| Term | Meaning |
|---|---|
| Base URL | Local dev: http://localhost:3035 (match your NEXTAUTH_URL / dev port). Production: public origin from SERVER_URL / your domain — no trailing slash. |
| Path | App Router route; prepend base for a full URL (e.g. base + /api/twilio/voice/inbound). |
| Twilio URLs | Twilio Console must use HTTPS public URLs; local dev typically uses ngrok (or similar) and the same path suffixes below. |
Twilio Voice — SDK, TwiML, status & recovery
| Method | Path | Purpose |
|---|---|---|
GET |
/api/twilio/token |
Session-scoped Twilio Access Token for the Voice SDK (Twilio.Device). Identity from session only. |
POST |
/api/twilio/voice |
TwiML App compatibility: delegates to outbound handler (same as /api/twilio/voice/outbound). |
POST |
/api/twilio/voice/outbound |
Twilio Voice webhook — outbound TwiML; insufficient balance message / hang-up; <Dial timeLimit=…> from max-duration logic. |
POST |
/api/twilio/voice/inbound |
Twilio Voice webhook — PSTN → browser Client; resolves TwilioNumber → user; wallet gate + <Dial timeLimit> + optional inbound low-balance prompt audio. |
GET |
/api/twilio/voice/max-duration-preview |
Logged-in preview of max-duration decision (query: iso, e.g. ?iso=US). Returns retail rates, safeMaxSeconds, insufficient. |
POST |
/api/twilio/call-status |
Twilio status callback — call lifecycle, settlement. |
POST |
/api/twilio/recording-status |
Twilio recording status webhook (validated signature; recording metadata). |
POST |
/api/twilio/client-call-metrics |
Browser fallback timing + provisional Call rows when callbacks lag. |
GET/POST |
/api/twilio/sync-cost-jobs |
Run due delayed cost-sync jobs (?limit=50). If CRON_SECRET is set: Authorization: Bearer <CRON_SECRET>. |
GET/POST |
/api/twilio/refresh-voice-pricing-csv |
Rebuild data/OutboundVoicePricing.csv from Twilio Pricing API v2 Voice Countries (pricing.twilio.com/v2). Same Bearer / dev rules as sync-cost-jobs. Local script: npm run refresh-outbound-voice-pricing-csv. Vercel cron: vercel.json → /api/twilio/refresh-voice-pricing-csv (weekly). |
GET/POST |
/api/twilio/sync-calls |
Recovery: pull Twilio calls, upsert Call, settle when priced (?days=1&limit=200&refreshDetails=true&pendingOnly=false). Session, or TWILIO_SYNC_CALLS_SECRET / CRON_SECRET as Bearer. |
GET |
/api/twilio/call-holds |
Inspect call holds for current user (admins: ?userId=&status=). Example: ?status=PENDING. |
POST |
/api/twilio/outgoing-caller-id/validation-status |
Twilio Validation Request status callback — registers Verified Caller ID on success (VerificationStatus). |
POST |
/api/twilio/custom-caller-id/voice |
(Legacy / unused) Custom TwiML verification — replaced by Twilio validationRequests API. |
POST |
/api/twilio/custom-caller-id/confirm |
(Legacy / unused) |
POST |
/api/twilio/custom-caller-id/status |
(Legacy / unused) Optional cost logs if old flow were used. |
GET/POST |
/api/twilio/call-holds/cleanup |
Expire stale pending holds (?limit=500). If CRON_SECRET is set: Bearer auth. |
Example (local)
http://localhost:3035/api/twilio/voice/max-duration-preview?iso=US
http://localhost:3035/api/twilio/sync-cost-jobs?limit=50
http://localhost:3035/api/twilio/refresh-voice-pricing-csv
http://localhost:3035/api/twilio/sync-calls?days=1&limit=200&refreshDetails=true&pendingOnly=false
http://localhost:3035/api/twilio/call-holds?status=PENDING
http://localhost:3035/api/twilio/call-holds?userId=<id>&status=PENDING
http://localhost:3035/api/twilio/call-holds/cleanup?limit=500
| Path | Main implementation | Notable libraries |
|---|---|---|
/api/twilio/voice/max-duration-preview |
app/api/twilio/voice/max-duration-preview/route.ts |
lib/billing/twilio-voice-pricing.ts |
/api/twilio/voice/outbound |
app/api/twilio/voice/outbound/route.ts |
lib/call-max-duration.ts |
/api/twilio/voice/inbound |
app/api/twilio/voice/inbound/route.ts |
getUserFromTwilioNumber() in lib/twilio.ts |
/api/twilio/call-status |
app/api/twilio/call-status/route.ts |
lib/call-billing.ts, lib/wallet.ts |
/api/twilio/client-call-metrics |
app/api/twilio/client-call-metrics/route.ts |
provisional Call, CallSession helpers |
/api/twilio/sync-cost-jobs |
app/api/twilio/sync-cost-jobs/route.ts |
lib/call-cost-sync.ts |
/api/twilio/refresh-voice-pricing-csv |
app/api/twilio/refresh-voice-pricing-csv/route.ts |
lib/twilio/refresh-outbound-voice-pricing-csv.ts → data/OutboundVoicePricing.csv |
/api/twilio/sync-calls |
app/api/twilio/sync-calls/route.ts |
recovery / settlement |
/api/twilio/call-holds |
app/api/twilio/call-holds/route.ts |
— |
/api/twilio/call-holds/cleanup |
app/api/twilio/call-holds/cleanup/route.ts |
— |
Twilio SMS — Console webhooks vs app API
Configure incoming and status URLs in Twilio Console using your public base + path (same idea as voice).
| Method | Path | Purpose |
|---|---|---|
POST |
/api/twilio/sms/inbound |
Twilio incoming message webhook → processInboundSms() / conversations. |
POST |
/api/twilio/sms-status |
Twilio outbound SMS status callback (queued → delivered / failed); lib/sms-status-precedence.ts. |
POST |
/api/sms/send |
Authenticated outbound SMS from dashboard (not a Console URL). |
Example (local)
http://localhost:3035/api/twilio/sms/inbound
http://localhost:3035/api/twilio/sms-status
| Path | Main implementation | Core logic |
|---|---|---|
/api/twilio/sms/inbound |
app/api/twilio/sms/inbound/route.ts |
lib/sms-inbound.ts |
/api/twilio/sms-status |
app/api/twilio/sms-status/route.ts |
lib/sms-status-precedence.ts |
/api/sms/send |
app/api/sms/send/route.ts |
lib/sms-send.ts |
SMS — multiple numbers per user
Inbound (processInboundSms() in lib/sms-inbound.ts):
- Twilio sends
To= the Twilio number that received the SMS. Ringvoo resolves the owner withTwilioNumberwherephoneNumbermatches that E.164 (normalized). - If no
TwilioNumberrow exists forTo, the message is not stored (unknown number). - If the row has a linked
VirtualNumber, inbound is accepted only whenVirtualNumber.statusisactiveorpast_due. - One user can own many
TwilioNumberrows; eachphoneNumberis globally unique, so each inboundTomaps to exactly one owner.
Outbound (sendUserSms() in lib/sms-send.ts, called from POST /api/sms/send):
- The client must send
fromNumber(which line to use). The server checksTwilioNumberfor(userId = session user, phoneNumber = fromNumber)— you cannot send from a number you do not own. - The same
VirtualNumberactive/past_due rule applies as for inbound. - Outbound destinations are restricted to US/Canada (
+1) inlib/sms-send.ts.
Conversations (SmsConversation):
- Threads are keyed by
(userId, twilioNumberId, remoteNumber)— one thread per owned local line and remote party. If the same user texts the same contact from two different lines, they get two separate conversations.
Dashboard (components/dashboard/sms/SmsInboxClient.tsx):
- Loads numbers via
GET /api/user/twilio-numbers, defaults the composefromNumberto the first number, and lets the user select another owned line when sending.
Dashboard — wallet, calls, numbers (authenticated)
| Method | Path | Purpose |
|---|---|---|
GET |
/api/user/wallet |
Wallet + hold-aware spendable balance (balance, activeHoldsUsd, availableBalanceUsd). |
GET |
/api/user/wallet/transactions |
Recent wallet transactions (paginated slice). |
GET |
/api/user/recent-calls |
Dialer Recent tab data (session user). |
GET |
/api/user/twilio-numbers |
User’s TwilioNumber rows + configured voice E.164 from env. |
| Path | File |
|---|---|
/api/user/wallet |
app/api/user/wallet/route.ts |
/api/user/wallet/transactions |
app/api/user/wallet/transactions/route.ts |
/api/user/recent-calls |
app/api/user/recent-calls/route.ts |
/api/user/twilio-numbers |
app/api/user/twilio-numbers/route.ts |
Public — marketing / unauthenticated pricing helpers
| Method | Path | Purpose |
|---|---|---|
GET |
/api/public/voice-rates |
Retail landline/mobile per-minute for ?iso=US (two-letter country). |
GET |
/api/public/voice-rate-floor |
Lowest retail USD/min from bundled CSV × multiplier (“up to X minutes” floor). |
GET |
/api/public/dialer-footer-preview |
Landing dialer footer: starting rate + max time for fixed demo balance (DialerPreview). Query: ?iso=US (two-letter country). |
Example (local)
http://localhost:3035/api/public/voice-rates?iso=US
http://localhost:3035/api/public/voice-rate-floor
http://localhost:3035/api/public/dialer-footer-preview?iso=US
| Path | File |
|---|---|
/api/public/voice-rates |
app/api/public/voice-rates/route.ts |
/api/public/voice-rate-floor |
app/api/public/voice-rate-floor/route.ts |
/api/public/dialer-footer-preview |
app/api/public/dialer-footer-preview/route.ts |
SMS — dashboard APIs (authenticated)
| Method | Path | Purpose |
|---|---|---|
GET |
/api/sms/conversations |
List conversations for session user. |
GET |
/api/sms/conversations/:conversationId/messages |
Messages in one conversation. |
POST |
/api/sms/conversations/:conversationId/read |
Mark conversation read. |
GET |
/api/sms/logs |
SMS log lines (dashboard). |
GET |
/api/sms/usage |
Usage / quota style metrics for SMS. |
:conversationId = Prisma SmsConversation.id.
| Path pattern | File |
|---|---|
/api/sms/conversations |
app/api/sms/conversations/route.ts |
/api/sms/conversations/[conversationId]/messages |
app/api/sms/conversations/[conversationId]/messages/route.ts |
/api/sms/conversations/[conversationId]/read |
app/api/sms/conversations/[conversationId]/read/route.ts |
/api/sms/logs |
app/api/sms/logs/route.ts |
/api/sms/usage |
app/api/sms/usage/route.ts |
Virtual numbers — search, checkout, subscription
| Method | Path | Purpose |
|---|---|---|
GET |
/api/numbers/search |
Search available numbers (country US/CA; optional areaCode, contains). |
POST |
/api/numbers/create-checkout |
Stripe checkout for purchasing a virtual number. |
POST |
/api/numbers/manage-subscription |
Portal / manage subscription for owned numbers. |
POST |
/api/numbers/retry-provisioning |
Retry failed provisioning after payment. |
PATCH |
/api/numbers/:id |
Update friendly name for the user’s virtual number (body.friendlyName). |
| Path pattern | File |
|---|---|
/api/numbers/search |
app/api/numbers/search/route.ts |
/api/numbers/create-checkout |
app/api/numbers/create-checkout/route.ts |
/api/numbers/manage-subscription |
app/api/numbers/manage-subscription/route.ts |
/api/numbers/retry-provisioning |
app/api/numbers/retry-provisioning/route.ts |
/api/numbers/[id] |
app/api/numbers/[id]/route.ts |
Billing, Stripe & promos
| Method | Path | Purpose |
|---|---|---|
POST |
/api/billing/checkout-session |
Wallet top-up / checkout session. |
POST |
/api/billing/setup-payment-method |
Save default payment method (setup session). |
GET |
/api/billing/checkout-return |
Post-checkout return handling. |
GET |
/api/billing/rewards |
First-purchase rewards / coupon state. |
POST |
/api/billing/promo/validate |
Validate promotion code. |
POST |
/api/billing/post-purchase-feedback |
Post-purchase feedback capture. |
GET/PATCH |
/api/user/billing/auto-topup |
Auto top-up settings. |
POST |
/api/webhooks/stripe |
Stripe webhook endpoint. |
| Path | File |
|---|---|
/api/billing/checkout-session |
app/api/billing/checkout-session/route.ts |
/api/billing/setup-payment-method |
app/api/billing/setup-payment-method/route.ts |
/api/billing/checkout-return |
app/api/billing/checkout-return/route.ts |
/api/billing/rewards |
app/api/billing/rewards/route.ts |
/api/billing/promo/validate |
app/api/billing/promo/validate/route.ts |
/api/billing/post-purchase-feedback |
app/api/billing/post-purchase-feedback/route.ts |
/api/user/billing/auto-topup |
app/api/user/billing/auto-topup/route.ts |
/api/webhooks/stripe |
app/api/webhooks/stripe/route.ts |
Admin
| Method | Path | Purpose |
|---|---|---|
GET |
/api/admin/users/role |
Read user role by ?email= (super-admin only). |
PATCH |
/api/admin/users/role |
Promote / demote role. Body: {"email":"…","role":"ENTERPRISE_ADMIN"}. |
GET |
/api/admin/sms-risk |
Super-admin SMS risk rows (negative-balance users + inbound negative_balance_floor skip counters for 24h/7d). |
POST |
/api/admin/users/:id/topup-reminder |
Super-admin manual top-up reminder email trigger for one user. |
Example (local)
http://localhost:3035/api/admin/users/role?email=user@example.com
| Path | File |
|---|---|
/api/admin/users/role |
app/api/admin/users/role/route.ts |
/api/admin/sms-risk |
app/api/admin/sms-risk/route.ts |
/api/admin/users/[id]/topup-reminder |
app/api/admin/users/[id]/topup-reminder/route.ts |
Auth & placeholders
| Method | Path | Purpose |
|---|---|---|
* |
/api/auth/[...nextauth] |
NextAuth catch-all (OAuth, session, callbacks). |
POST |
/api/auth/register |
Registration. |
POST |
/api/auth/forgot-password |
Password reset request. |
POST |
/api/auth/reset-password |
Password reset confirm. |
GET |
/api/auth/verify-email |
Email verification. |
GET |
/api/auth/token |
Legacy placeholder; use /api/twilio/token for Voice SDK. |
POST |
/api/webhooks |
Placeholder generic webhook (prefer specific Twilio routes above). |
POST |
/api/call |
Placeholder server-initiated outbound (not used by in-app dialer). |
GET |
/api/users |
Placeholder user/wallet JSON. |
How rates are used
- Max duration limiter uses the highest country rate (retail):
getMaxRetailRatePerMinuteUsdForIsoCountry()inlib/billing/twilio-voice-pricing.ts - Inbound max duration / hold limiter uses the highest inbound country rate (retail):
getMaxInboundRetailRatePerMinuteUsdForIsoCountry()inlib/billing/twilio-voice-pricing.ts - Marketing preview uses the lowest country rate (retail):
getMarketingStartingRetailRatePerMinuteUsd()inlib/billing/twilio-voice-pricing.ts - Final customer debit uses actual Twilio call cost × multiplier:
splitTwilioAndRetailCharge()inlib/billing/voice-pricing.ts - “Available balance” for voice admission is the wallet gross balance (holds are legacy/no-op):
getAvailableBalanceUsd()inlib/call-holds.ts(returnswallet.balancewithactiveHoldsUsd = 0) - Client metrics can still supply fallback duration when Twilio callbacks lag (used to improve provisional rows / settlement timing), but it is not a financial “hold compression” mechanism.
Inbound ownership model
Inbound ownership is determined by Ringvoo's database, not by Twilio subaccounts or per-user Twilio ownership.
- Twilio knows only that the purchased phone number belongs to the Ringvoo Twilio account.
- Ringvoo decides which app user owns that number by storing a mapping in
TwilioNumber. - The canonical ownership lookup is
getUserFromTwilioNumber()inlib/twilio.ts. - That lookup reads
TwilioNumber.phoneNumber -> TwilioNumber.userId. TwilioNumber.phoneNumberis unique globally, so each inboundTonumber resolves to exactly one owner.- A user can own multiple numbers (multiple
TwilioNumberrows sharing the sameuserId). - If a
TwilioNumberrow is linked to aVirtualNumber, inbound routing is allowed only whenVirtualNumber.statusisactiveorpast_due. - If no
TwilioNumberrow exists for the inboundTonumber, Ringvoo does not route that inbound call to a browser client.
Example:
{
"phoneNumber": "+18153678725",
"userId": "cmne9lz3i0000z27wovrrqhgs",
"friendlyName": "Main Twilio number"
}
Meaning:
- the Twilio number is owned at Twilio-account level by Ringvoo
- Ringvoo routes inbound calls for that number to the user
cmne9lz3i0000z27wovrrqhgs - Ringvoo also bills inbound usage for that number to that same user
This is the expected SaaS model for future number purchase as well:
- Ringvoo buys the number using the shared Twilio account
- Ringvoo inserts a
TwilioNumberrow for the purchasing user - inbound calls to that number resolve back to that user via
TwilioNumber
Development/testing note:
- Manually inserting
TWILIO_PHONE_NUMBERintoTwilioNumberis a valid local test setup for inbound routing. - In production flow, numbers are expected to be created through virtual-number provisioning, which also creates/links
VirtualNumberstatus used by inbound eligibility checks.
Inbound billing flow
Main files:
app/api/twilio/voice/inbound/route.tsapp/api/twilio/call-status/route.tslib/call-billing.tslib/voice-call-validation.ts,lib/voice-call-sessions.ts,lib/voice-dial-time-limit.tslib/call-holds.ts(legacy API surface; holds are no-op in current schema)lib/call-cost-sync.tscomponents/dashboard/TwilioProvider.tsx
Lifecycle:
- Twilio sends inbound webhook to
POST /api/twilio/voice/inbound(local:http://localhost:3035/api/twilio/voice/inbound) - Ringvoo resolves the called Twilio number in
TwilioNumber - Ringvoo runs the same wallet admission path as outbound (
validateVoiceCallStart) using an inbound max retail estimate (getInboundVoiceEstimateForIso) - On success, Ringvoo creates a
CallSessionand sets<Dial timeLimit>fromcomputeVoiceDialTimeLimitSeconds(includes low-balance stepping when applicable) - Ringvoo returns TwiML to dial the browser client for that user
- Twilio emits callbacks for the browser/client leg and terminal completion
- Ringvoo resolves the correct billable leg and settles the call through
upsertCallAndSettleWallet() - Ringvoo debits the wallet (estimated-first when Twilio
priceis late) and ends/binds matchingCallSessionrows (noCallHoldsettlement)
Parent and child Twilio legs
Inbound browser-connected calls usually have 2 Twilio call legs:
- Parent leg: PSTN caller → Twilio number
- Child leg: Twilio →
client:<userId>
For inbound settlement, the canonical persisted/billed call is the child billed leg:
Call.twilioCallSid= child billable legCall.twilioParentCallSid= parent inbound legCallSession.twilioParentCallSid/ optionalbillableCallSidtrack the parent→child relationship for estimated settlement + duration fallbacks
Why:
- Twilio's final priced/completed browser-connected call is the leg used for actual settlement
- the parent leg is still important for correlation and session binding
Outbound: dashboard dialer (browser → PSTN)
When a user calls a normal phone number from the WebRTC dialer, Twilio still uses two conceptual legs:
-
Parent leg (browser → your TwiML App)
Twilio’sFromon webhooks for this leg isclient:<userId>— the Voice SDK identity from/api/twilio/token(same asUser.idin Prisma). This is not the caller ID the callee sees. -
Child leg (Twilio → callee’s number)
The PSTN leg usescallerIdfrom TwiML (<Dial callerId="…">) inPOST /api/twilio/voice/outbound. That value is the Ringvoo public number, an owned virtual number, or a verified custom caller ID, depending on the user’s “Call from” selection.
Reading it in Twilio Console
- Open Monitor → Logs → Calls (or Develop → Monitor → Call logs, depending on Console layout).
- Paste the
CallSidfrom Ringvoo server logs (or search by destination number / time). - Open the call; if Twilio shows related calls or a child call, open the leg whose To is the destination country number (e.g.
+971…). On that leg, From should match the PSTN caller ID you chose (e.g.+1507…), notclient:…. - The leg where From is
client:…is the browser leg; use it only to correlate with the user session, not as “what number was displayed.”
Ringvoo logs
With TWILIO_VOICE_LOG_OUTBOUND=true, outbound TwiML generation logs twilioDeviceFrom (always client:… on the SDK leg), pstnCallerId (the E.164 used on <Dial>), and twilioAccountAuthorizesCallerId when “Call from” is owned/custom: true only if that E.164 is an Incoming Phone Number or Verified Caller ID on the same Twilio Account SID. Ringvoo’s in-app OTP does not by itself register the number with Twilio; if this flag is false, add the number under Twilio Console → Phone Numbers → Verified Caller IDs (or use a Twilio-purchased number). Billing-related logs may still show From as client:… because they follow Twilio’s parent-leg fields; for display and accounting, prefer the persisted Call.fromNumber / Call.toNumber once upsertCallAndSettleWallet has normalized the billable leg.
Optional: TWILIO_FALLBACK_CALLER_ID_IF_NOT_TWILIO_AUTHORIZED=true forces fallback to TWILIO_PHONE_NUMBER when Twilio would not honor the selected PSTN caller ID (connectivity over custom CLI).
Caller-ID source of truth and API usage
Ringvoo intentionally uses two checks for custom caller IDs:
- Product permission (Ringvoo DB)
UserCallerId.verified=truemeans the user can choose that number in the dialer. - Twilio account authorization (Twilio REST)
isTwilioDialCallerIdAllowed(e164)checks Twilio Incoming Numbers + Outgoing Caller IDs on this Account SID.
Important:
- DB verification and Twilio authorization are related but not identical states.
- Outbound TwiML still sets
<Dial callerId="...">from selected mode; Twilio then decides network-level presentation behavior. - If Twilio authorization is false and fallback env is off, Ringvoo logs a warning and still sends the selected callerId.
API/route usage in this flow:
POST /api/user/caller-ids:- normalizes E.164
- enforces cooldown
- calls Twilio
validationRequests.createwhen needed - stores
verificationCode,verificationCallSid,expiresAt, and status metadata
POST /api/twilio/outgoing-caller-id/validation-status:- Twilio webhook with
VerificationStatus - marks verified on success
- Twilio webhook with
GET /api/user/caller-ids:- returns caller IDs for modal/dropdown
- syncs pending rows against Twilio authorization
- normalizes stale in-flight verification statuses after expiry
POST /api/twilio/voice/outbound:- applies
CallFromMode(public,phone,custom) - sets
<Dial callerId> - optionally falls back to
TWILIO_PHONE_NUMBER
- applies
CallFromMode reference (dialer → TwiML)
CallFromMode is sent from DialerPreview to POST /api/twilio/voice/outbound and controls caller ID source:
public→ useTWILIO_PHONE_NUMBERphone→ use selected owned number (CallFromNumber)custom→ use selected verified custom caller ID (CallFromNumber)
Server log fields for each outbound attempt:
callFromModepstnCallerIdtwilioAccountAuthorizesCallerId(forphone/custom)
Practical debugging rule:
- If the callee sees unexpected caller ID, first confirm
callFromMode+pstnCallerIdin[Twilio Voice] Outbound call TwiML generated. - If
callFromMode: 'public', the dialer sent public mode; this is not Twilio fallback logic. - If
callFromMode: 'custom'andtwilioAccountAuthorizesCallerId: false, behavior depends on fallback env and Twilio/carrier handling.
Recent bug note (fixed):
- A UI state desync could occasionally show a selected number label while sending
CallFromMode='public'. - Dialer now performs a pre-connect consistency check:
- if label matches an owned number, force
phone - if label matches a verified custom number, force
custom - else keep
public
- if label matches an owned number, force
Custom caller-ID behavior matrix (outbound PSTN)
| User selection | Twilio authorization (isTwilioDialCallerIdAllowed) |
TWILIO_FALLBACK_CALLER_ID_IF_NOT_TWILIO_AUTHORIZED |
<Dial callerId> sent by Ringvoo |
Expected presentation behavior |
|---|---|---|---|---|
public |
n/a | n/a | TWILIO_PHONE_NUMBER |
Callee generally sees Twilio public number. |
phone (owned number) |
true | any | owned number | Callee generally sees owned number. |
custom |
true | any | custom number | Callee generally sees custom number. |
custom |
false | false | custom number | Ringvoo warns in logs; Twilio/carrier behavior may vary (reject/substitute/present). Not guaranteed. |
custom |
false | true | TWILIO_PHONE_NUMBER |
Deterministic app fallback; callee should see public number. |
Scenario: custom caller ID removed from Twilio Console
Observed in testing: after removing a custom caller ID from Twilio Console, some calls still showed that number to the callee while logs reported twilioAccountAuthorizesCallerId=false.
How to interpret this correctly:
- Treat this as runtime/network carrier behavior, not a guarantee.
- Ringvoo did its part by detecting unauthorized state via Twilio REST.
- Without fallback env, Ringvoo still sends selected custom callerId in TwiML.
- Twilio/carrier may still present that caller ID on some attempts/routes, or may substitute/reject on others.
Operational recommendation:
- If you want deterministic behavior after de-authorization, set
TWILIO_FALLBACK_CALLER_ID_IF_NOT_TWILIO_AUTHORIZED=true. - If you want strict policy, keep custom IDs synchronized between Ringvoo and Twilio account state.
Verification modal lifecycle scenarios (custom caller ID)
| Scenario | Twilio/DB state | Expected UI state |
|---|---|---|
| Verification call placed, user answers and enters code | verified=true via webhook/sync |
Row moves to Verified, code hint clears. |
| Verification call no-answer / no digits | status may end while verified=false |
Modal should leave Verifying.../Processing... after poll/expiry and allow retry. |
| Twilio callback delayed/missed | stale in-flight status in DB | GET /api/user/caller-ids now normalizes expired stale in-flight rows to completed so UI does not freeze. |
| Retry cooldown active | nextAttemptAt > now |
UI shows wait message; API returns 429 + Retry-After. |
Settlement scenarios for custom caller ID calls
Custom caller ID can affect ownership resolution on billing webhooks:
frommay be a custom E.164 that is not mapped inTwilioNumber.- Owner resolution falls back through Twilio client identities (
client:<userId>) and/or parent/child SID correlation paths inlib/call-billing.ts(notCallHold).
Settlement outcomes:
- terminal failed-like statuses (
busy,failed,no-answer,canceled) settle as zero-charge - if Twilio price is delayed, cost-sync job may be required in rare cases (
/api/twilio/sync-cost-jobs)
Inbound charging rules
Inbound charging is intentionally simple:
- ringing only: no charge
- rejected: no charge
- no-answer: no charge
- busy: no charge
- failed: no charge
- caller hangs up before answer: no charge
- answered inbound call: billable
For a successful answered inbound call:
answeredAtis storedbillableDurationSecondsis set from the final/fallback durationisBillable = truebillingStatus = settledafter final settlement
Inbound admission + <Dial timeLimit>
Inbound TwiML uses the same admission + max talk time approach as outbound, but priced with inbound max retail:
At call start:
- Ringvoo checks
wallet.balance > 0(validateVoiceCallStart) - Ringvoo estimates inbound max retail $/min (
getInboundVoiceEstimateForIso) - Ringvoo creates a
CallSessionrow for settlement/idempotency - Ringvoo sets
<Dial timeLimit>fromcomputeVoiceDialTimeLimitSeconds(includes low-balance stepping when applicable)
For the currently intended product scope, inbound numbers are expected to be US/Canada numbers purchased inside Ringvoo.
Important distinction:
<Dial timeLimit>uses a conservative highest inbound retail estimate for the owning number’s country- Final charge uses the actual Twilio call
pricefor the completed billed leg, then applies the Ringvoo multiplier (CallSettlementreconciles estimated vs final when needed)
Custom caller-ID verification billing note
- Verification calls are currently free for users (no wallet debit).
- Twilio verification call cost is currently platform-paid.
- To inspect Twilio cost/duration in terminal for these calls, enable:
TWILIO_LOG_CUSTOM_CALLER_ID_COST=true
Inbound settlement and idempotency
Final settlement happens in upsertCallAndSettleWallet() in lib/call-billing.ts.
Protection layers:
- one wallet debit per logical call via
Transaction.referenceId + source - inbound wallet debits use
source = "inbound_call" CallSettlement.callSidis the idempotency anchor for the billed leg;CallSessionbinds parent/child SIDs for estimated settlement paths- serializable Prisma transactions are used for settlement
pg_advisory_xact_lock(hashtext(callSid))serializes settlement work for the same call SID- delayed price sync uses
CallCostSyncJob.callSidunique retry jobs
This means duplicated or delayed Twilio callbacks should not create:
- duplicate
Callrows for the same child billed leg - duplicate wallet deductions
- duplicate settlement rows for the same priced leg
Client fallback metrics
POST /api/twilio/client-call-metrics is best-effort fallback metadata, not the primary source of financial truth.
It helps Ringvoo recover:
acceptedAtendedAt- fallback duration
- parent/child relation when client-side timing is available earlier than Twilio's final priced callback
Primary use:
- improve
answeredAt - improve fallback duration
- improve settlement recovery if Twilio callback timing is delayed
Expected final DB shape for a good answered inbound call
Call:
direction = 'inbound'twilioCallSid = child billed legtwilioParentCallSid = parent inbound legstatus = 'completed'answeredAtpopulateddurationpopulatedbillableDurationSeconds > 0isBillable = truetwilioCost > 0cost > 0billingStatus = 'settled'
CallSession:
twilioParentCallSid = parent inbound legbillableCallSideventually matches the child billed legstatus = 'ENDED'after terminal processing
CallSettlement:
callSid = child billed legamountUsd = Call.cost(retail charged)twilioCarrierCostUsdpopulated when Twilio price is known (may be filled after an estimated-first path)
Transaction:
type = 'CALL'source = 'inbound_call'referenceId = Call.twilioCallSidamount = Call.cost
Frontend components involved
- Dialer UI (toast/audio gates + a few modals like end-of-limit / contacts / caller-id flows):
components/marketing/DialerPreview.tsx
- Twilio client lifecycle (online/offline, register, incoming/outgoing wiring):
components/dashboard/TwilioProvider.tsx
Voice UX env knobs
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_VOICE_BACK_TO_BACK_HINT_WINDOW_MS |
Back-to-back debounce window while settlement/pricing catches up. Default 4000 ms (min 500, max 15000). Used by components/marketing/DialerPreview.tsx. |
NEXT_PUBLIC_DIALER_RINGBACK_AUDIO_SRC |
Local outbound ringback URL (default /sounds/ringvoo-ringback-cc0.mp3). |
NEXT_PUBLIC_DIALER_CONNECT_MESSAGE_AUDIO_SRC |
Local outbound “please wait …” line (default /sounds/ringvoo-connect-message.wav). |
NEXT_PUBLIC_DIALER_HANGUP_TONE_AUDIO_SRC |
Local hangup tone (default /sounds/ringvoo-end-call.mp3). |
NEXT_PUBLIC_INCOMING_RINGTONE_AUDIO_SRC |
Browser inbound ringtone (default /sounds/ringvoo-incoming.mp3). |
NEXT_PUBLIC_DIALER_LOW_BALANCE_BLOCK_AUDIO_SRC / NEXT_PUBLIC_DIALER_LOW_BALANCE_MEMBER_BLOCK_AUDIO_SRC |
Marketing dialer low-balance block cues (user vs org member). |
NEXT_PUBLIC_DIALER_FINALIZING_AUDIO_SRC / NEXT_PUBLIC_DIALER_FINALIZING_MESSAGE |
Marketing dialer back-to-back “finalizing” cue + toast text. |
NEXT_PUBLIC_DIALER_CALL_NOT_COMPLETED_AUDIO_SRC |
Marketing dialer early-failure cue (before connect). |
Full policy + audio map: docs/RINGVOO_SYSTEM_NOTES.md.
Voice log env knobs
| Variable | Purpose |
|---|---|
TWILIO_SERVER_LOGS |
Global master switch for Twilio server logs. |
TWILIO_SERVER_LOG_SCOPES |
Optional scope filter: voice, billing, recording, rest, sms. |
TWILIO_VOICE_LOG_INBOUND |
Optional inbound voice override (preferred; aligns with TWILIO_SMS_LOG_*). When set, overrides global voice logging for app/api/twilio/voice/inbound only. Legacy: TWILIO_INBOUND_SERVER_LOGS if unset. |
TWILIO_VOICE_LOG_OUTBOUND |
Optional outbound voice override for app/api/twilio/voice/outbound only. Legacy: TWILIO_OUTBOUND_SERVER_LOGS if unset. |