Skip to content

Phone Number Provisioning System Documentation ​

Single reference for how the "Number" system works end to end: SuperAdmin → Organization → Customer. Covers provider registration, the pricing/margin cascade, browse, purchase, renewal, release, the adapter layer (VoiceLink real / test / mock), the full API surface, and a testing guide.

Last updated: 2026-09-04

Table of contents ​

  1. Overview
  2. High-level flow
  3. Data model
  4. Pricing & margin model (stacked)
  5. Setup sequence
  6. Purchase flow
  7. Renewal & release
  8. Adapter layer
  9. VoiceLink API mapping
  10. API surface & endpoints
  11. UI map
  12. Environment
  13. Testing guide (no real spend)
  14. Troubleshooting

1. Overview ​

The provisioning feature lets an Oktivo Customer buy a phone number (DID) that is purchased under Oktivo's own reseller account at an upstream provider (currently VoiceLink), with Oktivo's internal credit billing on top.

Three actors, three layers of control:

ActorControlsSees
SuperAdminRegisters providers + credentials; enables a provider per Org; sets the SuperAdmin margin (over provider cost)Provider cost, SuperAdmin price, platform-wide inventory
Organization (admin)Enables number-buying per Customer; sets the Org margin (over the SuperAdmin price)SuperAdmin price (its floor), final customer price, org-scoped inventory
CustomerBrowses + buys + releases numbersA single margin-inclusive price (no provider identity), owned numbers

Key principle: provider identity is concealed from the customer. They see an "Oktivo Number" with one final price; they never see VoiceLink, the raw cost, or any provider reference.


2. High-level flow ​


3. Data model ​

All collections live in the client backend Mongo DB.

CollectionPurposeKey fields
providersRegistered providersproviderUID, name (maps 1:1 to an adapter), mode (partner/byo_credentials), active, concealed, supportedDidTypes[], capabilities{purchase,mapping,renewal}, credentials{ providerIdentifier, encrypted(token), encryptedLogin{ username, encryptedPassword } }
OrgProviderEnablementWhich provider is enabled for which orgorgUID, providerUID, enabled, byoAllowCustomerCredentials
ProvisioningRatePricing per org+provider+kind+didTypeproviderCost, superadminMarginMode/superadminMarginPct/superadminRate, customerMarginMode/customerMarginPct/customerRate. didType = "*" is the global row; a real didType is a per-type override; channels use didType = null
ProviderSubUserOne VoiceLink client per org+customer+providerunique {orgUID,customerUID,providerUID} -> providerSubUserId
ProvisionedNumberOwnership mapping (the "owned number")mappingUID, orgUID, customerUID, providerUID, did, didType, status (pending/active/grace/released), autoRenew, nextRenewalDate, channels{count}, rate snapshots, providerOrderId, phoneUID/trunkUID
CustomerProviderCredentialBYO credentials (customer-owned provider)encrypted; never charged
ProvisioningAuditLogImmutable audit + reconciliation recordsactor, event, outcome, failureReason

4. Pricing & margin model (STACKED, multiplicative) ​

Implemented in services/provisioningPricing.service.ts; stored in ProvisioningRate.

The cascade ​

  • superadminRate = SuperAdmin stage output (a.k.a. "SuperAdmin price").
  • customerRate = Org stage output = final price the customer pays.
  • marginInclusiveRate = roundHalfUp2(customerRate), min 0.01.
  • retainedMargin = customerRate - superadminRate (the org's slice).
  • Rounding: half-up to 2 decimals at each stage so displayed = charged.

Flat vs percent ​

Partypercentflat
SuperAdmincost x (1 + pct/100)absolute value, floored at cost
OrgsuperadminRate x (1 + pct/100)absolute value, floored at superadminRate

Flat pins a price; the next stage's percent still stacks on the flat result.

Global vs per-DID-type ​

  • Row didType = "*" = global default for all DID types.
  • Row didType = "mobile" (etc.) = per-type override.
  • A per-type row inherits the global margin unless it carries its own explicit margin. Cost-only per-type rows created by cost capture still use the global margin.
  • Channels use didType = null (separate from DID pricing).

Worked examples ​

providerCostSuperAdminOrgsuperadminRatefinalCustomerPrice
250+30%+20%325.00390.00
250flat 300+20%300.00360.00
250+30%flat 400325.00400.00
2500% (none)0% (none)250.00250.00
null (not captured)anyanyRate unavailable

Provider cost capture ​

providerCost is the live VoiceLink price. It is written to the rate rows:

  1. Implicitly during browseOktivoNumbers each listing's cost is upserted per DID type + the global * row (best-effort; never fails the browse).
  2. Explicitly via Refresh costs (refreshProviderCosts) browses the provider per supported DID type and upserts costs.

If cost is missing, resolveCustomerRate returns available:false ("Rate unavailable") and purchase is blocked until a cost exists.

Preview / breakdown API ​

resolveProviderPriceBreakdowns(orgUID, providerUID, kind) returns one PriceBreakdown per supported DID type + the global row:

PriceBreakdown = {
  didType,                       // "*" for global, else the type
  providerCost,                  // live cost (may be null)
  superadmin: { mode, pct, rate } | null,
  customer:   { mode, pct, rate } | null,
  finalCustomerPrice,            // what the customer pays
  available, reason?
}
  • SuperAdmin table shows: Provider Cost -> SuperAdmin margin -> SuperAdmin price.
  • Org table shows: SuperAdmin price (floor) -> Org margin -> Final customer price.

Persistence entry points:

  • SuperAdmin: setSuperadminMargin(...) -> PUT /superadmin/provisioning/orgs/:orgUID/rates.
  • Org: setOrgMargin(...) -> PUT /admin/provisioning/rates.
  • Legacy flat calls (plain numeric rate, no mode) are treated as flat.

5. Setup sequence ​

Two-layer feature gate: a non-SuperAdmin purchase requires number_purchasing on BOTH the Org plan AND the Customer. Missing either -> blocked.


6. Purchase flow ​

  • Charge + mapping commit inside one Mongo transaction (all-or-none).
  • Idempotency-Key prevents double charges on retry.
  • Post-confirm failures emit a reconciliation audit record (confirmed order retained).

7. Renewal & release ​

Renewal (provisioningRenewal.service.ts, daily IST cron): for each active/grace partner number due within 24h charge the customer wallet, call VoiceLink renew, advance nextRenewalDate to the same day next month (clamped to month end). On insufficient balance -> 7-day grace, retry daily up to 7 times; grace expiry releases the number. BYO numbers are never charged/renewed.

Release (releaseNumber): provider-first call VoiceLink release, then mark the mapping released and revoke routing. If provider release fails, the mapping is retained active and flagged for manual review (audit log).


8. Adapter layer ​

The registry resolves a concrete adapter by provider.name + provider.mode. Adapters self-register at startup (services/providers/index.ts -> registerProviderAdapters(), called from server.ts).

Adapter nameBrowsePurchase / renew / releaseUse for
voicelinkreal APIreal (spends money)Production
voicelink-testreal APImocked (free)Testing with real inventory, no spend
voicelink-mockfake datamocked (free)Offline / CI (no VoiceLink account)

Interface: IProviderAdapter (services/providers/IProviderAdapter.ts) — authenticate, validateCredentials, loginWithPassword, createOrGetSubUser, browseDids, lockDid, addChannel, orderSummary, confirmOrder, mapDidToSubUser, renew, listPurchased, releaseDid.

Auth: VoiceLink uses a Sanctum bearer token obtained via POST /v1/auth/login (email/username + password). The token is stored encrypted; the login creds are also stored encrypted so the adapter auto-re-logins on HTTP 401 and persists the new token no SuperAdmin intervention on expiry.

DID id threading: VoiceLink browse returns a numeric id separate from the phone number. It is carried internally as DidListing.providerRef and threaded into lock/map. It is stripped by concealment and never reaches the customer.

Token lifecycle & auto-refresh ​

TEST vs MOCK adapters ​

  • voicelink-test (VoiceLinkTestAdapter.ts): delegates read-only/free calls (auth, login, browse, createOrGetSubUser, listPurchased) to the REAL adapter; short-circuits money-spending calls with synthetic success. confirmOrder returns test-order-<...>.
  • voicelink-mock (MockProviderAdapter.ts): fully offline; fake inventory (mobile 250, landline 200, toll_free 500) and synthetic success everywhere.

Source of truth: backend/docs/voicelink_api_manually_copy_pasted.txt. Base URL: https://app.voicelink.co.in/api. Auth: Authorization: Bearer {token}. Adapter file: services/providers/VoiceLinkAdapter.ts.

Adapter methodVoiceLink endpointRequestResponse fields used
loginWithPasswordPOST /v1/auth/login{ username, password }data.access_token
authenticateGET /v1/auth/user200 = token valid
validateCredentials (BYO)POST /v1/auth/login{ username, password }200 ok; 401/422 = invalid
createOrGetSubUserGET /v1/reseller/clients then POST /v1/reseller/client/createcreate: { first_name, last_name, username, email, password, channel_count, negative_threshold }reuse: match username -> id; create: data.client_id
browseDidsGET /v1/reseller/purchase/didsquery: country_code=+91, `type=<1 TF2 Mobile
lockDidPOST /v1/reseller/purchase/lock-did{ did_ids: [<numeric id>], months }data.order_id
addChannelPOST /v1/reseller/purchase/add-channel{ order_id, channel_count, months }data.channel_total
orderSummaryGET /v1/reseller/purchase/summary/{order_id}data.total, data.did_count, data.channel_count
confirmOrderPOST /v1/reseller/purchase/confirm{ order_id }data.purchase_order_id
mapDidToSubUserPOST /v1/reseller/client/map-did{ client_id, did_id: <numeric id>, user_status: 2, call_recording: 0 }200 ok
renewPOST /v1/reseller/renew/confirm{ did_purchase_ids: [<purchase_order_id>] }200 ok
listPurchasedGET /v1/reseller/did/purchasedoptional client_idrows list
releaseDidPOST /v1/reseller/did/release{ did_number: "<phone number>" }data.released_count

Notes:

  • All responses use the envelope { data, status, message }; parsing tolerates top-level or nested data.
  • DID types: 1=Toll-Free, 2=Mobile, 3=Landline map to toll_free, mobile, landline.
  • country_code MUST carry the leading + (e.g. +91), not an ISO code.
  • DID id vs phone number: browse returns a numeric id (lock/map) and a separate did_number (release). The id is threaded internally as providerRef (concealed).
  • confirm returns purchase_order_id, which renewal consumes not the draft order_id.
  • Auto-renew is always ON at VoiceLink for confirmed purchases (mandatory).

Purchase call ordering (real adapter) ​

Pre-confirm failure -> release held lock, no charge/mapping. Post-confirm map failure -> confirmed order retained + reconciliation record.


10. API surface & endpoints ​

SurfaceMountGuard
SuperAdmin/superadmin/provisioningverifyJwt + requireRole("superadmin")
Org admin/admin/provisioningverifyJwt + requireRole("admin") + requireOrg
Customer/provisioningverifyJwt + requireFeatureAccess("number_purchasing")

SuperAdmin (/superadmin/provisioning) ​

MethodPathPurpose
GET/adaptersAdapter catalog for the provider dropdown
GET/providersList active providers
POST/providersRegister provider (reactivates an inactive same-name row)
PUT/providers/:providerUIDUpdate mutable fields
POST/providers/:providerUID/deactivateSoft-deactivate
PUT/providers/:providerUID/credentialsStore credentials by raw bearer token
PUT/providers/:providerUID/credentials/loginStore credentials via username/password login
GET/providers/:providerUID/credentialsMasked credential view (last-4)
GET/orgs/:orgUID/providersProviders enabled for an org
POST/orgs/:orgUID/providers/:providerUID/enableEnable provider for org
POST/orgs/:orgUID/providers/:providerUID/disableDisable for org (retains owned numbers)
PUT/orgs/:orgUID/ratesSet SuperAdmin margin { providerUID, kind, didType, mode, pct?, flatRate? }
GET/orgs/:orgUID/providers/:providerUID/price-breakdownBulk price breakdown (global + per type)
POST/orgs/:orgUID/providers/:providerUID/refresh-costsRe-capture provider cost via live browse
GET/inventoryPlatform-wide inventory (AND filters + pagination)

Org admin (/admin/provisioning) ​

MethodPathPurpose
GET/providersProviders enabled for the admin's org
POST/customers/:customerUID/enablementEnable/disable number-buying for a customer
PUT/providers/:providerUID/byo-permissionToggle BYO permission (default off)
PUT/ratesSet Org margin { providerUID, kind, didType, mode, pct?, flatRate? }
GET/providers/:providerUID/price-breakdownOrg-scoped breakdown (shows final customer price)
POST/providers/:providerUID/refresh-costsRe-capture provider cost
GET/inventoryOrg-scoped inventory (per-customer filter, concealment)

Customer (/provisioning) ​

MethodPathPurpose
GET/numbers/availableBrowse Oktivo Numbers (single margin-inclusive price, no provider identity)
POST/numbers/purchaseBuy a DID { did, didType, channels, termMonths, direction?, providerUID? } (providerUID optional; resolved from DID)
GET/numbers/ownedOwned numbers + renewal status
POST/numbers/:mappingUID/releaseRelease an owned number
PUT/byo/:providerUID/credentialsConfigure BYO credentials (customer-owned provider)

Common status codes ​

CodeMeaning
400Validation (bad term/channels/didType), rate unavailable
402Insufficient wallet balance
403Feature/enablement denied, BYO not permitted, provider disabled for org
404Provider/customer/mapping not found
409Active-DID conflict, idempotency in-progress, provider inactive-on-enable
502Provider unreachable (login/release)

11. UI map (frontend) ​

PageRouteNotes
Number Management (SuperAdmin)/superadmin/providersTabs: Number Providers (CRUD + credentials) and Org Provisioning (Enablement / Margin table / Inventory)
Org Number Provisioning/admin/provisioningProviders & enablement, Margin table (final price preview), inventory
Oktivo Number (Customer)/oktivo-numbersBuy Numbers / My Numbers / BYO Setup tabs

Margin tables show a Global (all types) row + per-DID-type rows with a Percent/Flat toggle and live-computed price columns (SuperAdmin price / final customer price).


12. Environment ​

  • WEBHOOK_SECRET_ENCRYPTION_KEY (64-hex / 32 bytes) encrypts stored provider tokens and login creds. Already set in env/client-backend.env.
  • No env var for the VoiceLink token or base URL token is entered in the UI (stored encrypted); base URL is a constant in VoiceLinkAdapter.ts.

13. Testing guide (no real spend) ​

Use the voicelink-test provider: real browse (customer sees real numbers) but purchase/renew/release are short-circuited (no VoiceLink spend, real Oktivo wallet billing). Switch the org to the voicelink provider for production.

Prerequisites ​

  • Backend restarted; startup log shows Provider adapters registered: voicelink:partner, voicelink-mock:partner, voicelink-test:partner.
  • A VoiceLink reseller account (for the real browse).
  • The Org plan includes the number_purchasing feature.

SuperAdmin ​

  1. Number Management -> Number Providers -> Add Provider -> pick VoiceLink (Test), enter email/username + password -> Register.
  2. Org Provisioning -> Enablement -> select org -> toggle provider Enabled (stays on).
  3. Margin tab -> select org + provider -> Refresh costs (providerCost populates, e.g. 250) -> set Global Percent 30% -> Save (SuperAdmin price = 325).

Org admin ​

  1. Number Provisioning -> Providers & Enablement -> provider now listed -> select Customer -> Enable.
  2. Margin tab -> base SuperAdmin price 325 -> set Percent 20% -> Save (Final Customer Price = 390).

Customer ​

  1. Add credits so wallet >= final price x months (e.g. >= 390).
  2. Oktivo Number -> Buy Numbers -> real mobile DIDs at 390 (no provider identity). If "Rate unavailable": SuperAdmin Margin -> Refresh costs.
  3. Buy (term 1..60, channels 1..100) -> wallet debited 390 x months.
  4. My Numbers -> DID shows rate, renewal date, status active, auto-renew on.
  5. Release -> released, routing removed. No real VoiceLink call.

Cross-checks ​

  • SuperAdmin Inventory: DID under org/customer. Customer ledger: shows the debit. No VoiceLink spend (test adapter).

Going live ​

Switch the org to the voicelink provider. Purchases then debit the VoiceLink reseller wallet and buy real DIDs. Test with the cheapest DID + 1 channel first.


14. Troubleshooting ​

SymptomCauseFix
Customer sees "Rate unavailable"providerCost never capturedBrowse once, or SuperAdmin "Refresh costs"
Org "No providers enabled"Provider not enabled for that orgSuperAdmin -> Org Provisioning -> enable
Enable toggle stuck off(fixed) UI read a non-existent fieldReload app
"name is already in use" but list empty(fixed) inactive same-name providerRe-create now reactivates it
Purchase 400 (providerUID)(fixed) provider now resolved from DID
Numbers empty on customer pageOrg not enabled / no margin / no VoiceLink inventoryCheck enablement + Refresh costs

Last updated: