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.mdSMS (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.tsdata/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 with TwilioNumber where phoneNumber matches that E.164 (normalized).
  • If no TwilioNumber row exists for To, the message is not stored (unknown number).
  • If the row has a linked VirtualNumber, inbound is accepted only when VirtualNumber.status is active or past_due.
  • One user can own many TwilioNumber rows; each phoneNumber is globally unique, so each inbound To maps 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 checks TwilioNumber for (userId = session user, phoneNumber = fromNumber) — you cannot send from a number you do not own.
  • The same VirtualNumber active/past_due rule applies as for inbound.
  • Outbound destinations are restricted to US/Canada (+1) in lib/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 compose fromNumber to 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() in lib/billing/twilio-voice-pricing.ts
  • Inbound max duration / hold limiter uses the highest inbound country rate (retail):
    getMaxInboundRetailRatePerMinuteUsdForIsoCountry() in lib/billing/twilio-voice-pricing.ts
  • Marketing preview uses the lowest country rate (retail):
    getMarketingStartingRetailRatePerMinuteUsd() in lib/billing/twilio-voice-pricing.ts
  • Final customer debit uses actual Twilio call cost × multiplier:
    splitTwilioAndRetailCharge() in lib/billing/voice-pricing.ts
  • “Available balance” for voice admission is the wallet gross balance (holds are legacy/no-op):
    getAvailableBalanceUsd() in lib/call-holds.ts (returns wallet.balance with activeHoldsUsd = 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() in lib/twilio.ts.
  • That lookup reads TwilioNumber.phoneNumber -> TwilioNumber.userId.
  • TwilioNumber.phoneNumber is unique globally, so each inbound To number resolves to exactly one owner.
  • A user can own multiple numbers (multiple TwilioNumber rows sharing the same userId).
  • If a TwilioNumber row is linked to a VirtualNumber, inbound routing is allowed only when VirtualNumber.status is active or past_due.
  • If no TwilioNumber row exists for the inbound To number, 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:

  1. Ringvoo buys the number using the shared Twilio account
  2. Ringvoo inserts a TwilioNumber row for the purchasing user
  3. inbound calls to that number resolve back to that user via TwilioNumber

Development/testing note:

  • Manually inserting TWILIO_PHONE_NUMBER into TwilioNumber is 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 VirtualNumber status used by inbound eligibility checks.

Inbound billing flow

Main files:

  • app/api/twilio/voice/inbound/route.ts
  • app/api/twilio/call-status/route.ts
  • lib/call-billing.ts
  • lib/voice-call-validation.ts, lib/voice-call-sessions.ts, lib/voice-dial-time-limit.ts
  • lib/call-holds.ts (legacy API surface; holds are no-op in current schema)
  • lib/call-cost-sync.ts
  • components/dashboard/TwilioProvider.tsx

Lifecycle:

  1. Twilio sends inbound webhook to POST /api/twilio/voice/inbound (local: http://localhost:3035/api/twilio/voice/inbound)
  2. Ringvoo resolves the called Twilio number in TwilioNumber
  3. Ringvoo runs the same wallet admission path as outbound (validateVoiceCallStart) using an inbound max retail estimate (getInboundVoiceEstimateForIso)
  4. On success, Ringvoo creates a CallSession and sets <Dial timeLimit> from computeVoiceDialTimeLimitSeconds (includes low-balance stepping when applicable)
  5. Ringvoo returns TwiML to dial the browser client for that user
  6. Twilio emits callbacks for the browser/client leg and terminal completion
  7. Ringvoo resolves the correct billable leg and settles the call through upsertCallAndSettleWallet()
  8. Ringvoo debits the wallet (estimated-first when Twilio price is late) and ends/binds matching CallSession rows (no CallHold settlement)

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 leg
  • Call.twilioParentCallSid = parent inbound leg
  • CallSession.twilioParentCallSid / optional billableCallSid track 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:

  1. Parent leg (browser → your TwiML App)
    Twilio’s From on webhooks for this leg is client:<userId> — the Voice SDK identity from /api/twilio/token (same as User.id in Prisma). This is not the caller ID the callee sees.

  2. Child leg (Twilio → callee’s number)
    The PSTN leg uses callerId from TwiML (<Dial callerId="…">) in POST /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

  1. Open Monitor → Logs → Calls (or Develop → Monitor → Call logs, depending on Console layout).
  2. Paste the CallSid from Ringvoo server logs (or search by destination number / time).
  3. 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…), not client:….
  4. 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 NumbersVerified 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:

  1. Product permission (Ringvoo DB)
    UserCallerId.verified=true means the user can choose that number in the dialer.
  2. 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.create when needed
    • stores verificationCode, verificationCallSid, expiresAt, and status metadata
  • POST /api/twilio/outgoing-caller-id/validation-status:
    • Twilio webhook with VerificationStatus
    • marks verified on success
  • 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

CallFromMode reference (dialer → TwiML)

CallFromMode is sent from DialerPreview to POST /api/twilio/voice/outbound and controls caller ID source:

  • public → use TWILIO_PHONE_NUMBER
  • phone → use selected owned number (CallFromNumber)
  • custom → use selected verified custom caller ID (CallFromNumber)

Server log fields for each outbound attempt:

  • callFromMode
  • pstnCallerId
  • twilioAccountAuthorizesCallerId (for phone/custom)

Practical debugging rule:

  • If the callee sees unexpected caller ID, first confirm callFromMode + pstnCallerId in [Twilio Voice] Outbound call TwiML generated.
  • If callFromMode: 'public', the dialer sent public mode; this is not Twilio fallback logic.
  • If callFromMode: 'custom' and twilioAccountAuthorizesCallerId: 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

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:

  • from may be a custom E.164 that is not mapped in TwilioNumber.
  • Owner resolution falls back through Twilio client identities (client:<userId>) and/or parent/child SID correlation paths in lib/call-billing.ts (not CallHold).

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:

  • answeredAt is stored
  • billableDurationSeconds is set from the final/fallback duration
  • isBillable = true
  • billingStatus = settled after 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 CallSession row for settlement/idempotency
  • Ringvoo sets <Dial timeLimit> from computeVoiceDialTimeLimitSeconds (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 price for the completed billed leg, then applies the Ringvoo multiplier (CallSettlement reconciles 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.callSid is the idempotency anchor for the billed leg; CallSession binds 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.callSid unique retry jobs

This means duplicated or delayed Twilio callbacks should not create:

  • duplicate Call rows 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:

  • acceptedAt
  • endedAt
  • 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 leg
  • twilioParentCallSid = parent inbound leg
  • status = 'completed'
  • answeredAt populated
  • duration populated
  • billableDurationSeconds > 0
  • isBillable = true
  • twilioCost > 0
  • cost > 0
  • billingStatus = 'settled'

CallSession:

  • twilioParentCallSid = parent inbound leg
  • billableCallSid eventually matches the child billed leg
  • status = 'ENDED' after terminal processing

CallSettlement:

  • callSid = child billed leg
  • amountUsd = Call.cost (retail charged)
  • twilioCarrierCostUsd populated when Twilio price is known (may be filled after an estimated-first path)

Transaction:

  • type = 'CALL'
  • source = 'inbound_call'
  • referenceId = Call.twilioCallSid
  • amount = 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.