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
- Overview
- High-level flow
- Data model
- Pricing & margin model (stacked)
- Setup sequence
- Purchase flow
- Renewal & release
- Adapter layer
- VoiceLink API mapping
- API surface & endpoints
- UI map
- Environment
- Testing guide (no real spend)
- 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:
| Actor | Controls | Sees |
|---|---|---|
| SuperAdmin | Registers 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 |
| Customer | Browses + buys + releases numbers | A 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.
| Collection | Purpose | Key fields |
|---|---|---|
providers | Registered providers | providerUID, 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 } } |
OrgProviderEnablement | Which provider is enabled for which org | orgUID, providerUID, enabled, byoAllowCustomerCredentials |
ProvisioningRate | Pricing per org+provider+kind+didType | providerCost, superadminMarginMode/superadminMarginPct/superadminRate, customerMarginMode/customerMarginPct/customerRate. didType = "*" is the global row; a real didType is a per-type override; channels use didType = null |
ProviderSubUser | One VoiceLink client per org+customer+provider | unique {orgUID,customerUID,providerUID} -> providerSubUserId |
ProvisionedNumber | Ownership mapping (the "owned number") | mappingUID, orgUID, customerUID, providerUID, did, didType, status (pending/active/grace/released), autoRenew, nextRenewalDate, channels{count}, rate snapshots, providerOrderId, phoneUID/trunkUID |
CustomerProviderCredential | BYO credentials (customer-owned provider) | encrypted; never charged |
ProvisioningAuditLog | Immutable audit + reconciliation records | actor, 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
| Party | percent | flat |
|---|---|---|
| SuperAdmin | cost x (1 + pct/100) | absolute value, floored at cost |
| Org | superadminRate 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
| providerCost | SuperAdmin | Org | superadminRate | finalCustomerPrice |
|---|---|---|---|---|
| 250 | +30% | +20% | 325.00 | 390.00 |
| 250 | flat 300 | +20% | 300.00 | 360.00 |
| 250 | +30% | flat 400 | 325.00 | 400.00 |
| 250 | 0% (none) | 0% (none) | 250.00 | 250.00 |
| null (not captured) | any | any | Rate unavailable |
Provider cost capture
providerCost is the live VoiceLink price. It is written to the rate rows:
- Implicitly during
browseOktivoNumberseach listing's cost is upserted per DID type + the global*row (best-effort; never fails the browse). - 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 name | Browse | Purchase / renew / release | Use for |
|---|---|---|---|
voicelink | real API | real (spends money) | Production |
voicelink-test | real API | mocked (free) | Testing with real inventory, no spend |
voicelink-mock | fake data | mocked (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.confirmOrderreturnstest-order-<...>.voicelink-mock(MockProviderAdapter.ts): fully offline; fake inventory (mobile 250, landline 200, toll_free 500) and synthetic success everywhere.
9. VoiceLink API mapping
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 method | VoiceLink endpoint | Request | Response fields used |
|---|---|---|---|
loginWithPassword | POST /v1/auth/login | { username, password } | data.access_token |
authenticate | GET /v1/auth/user | 200 = token valid | |
validateCredentials (BYO) | POST /v1/auth/login | { username, password } | 200 ok; 401/422 = invalid |
createOrGetSubUser | GET /v1/reseller/clients then POST /v1/reseller/client/create | create: { first_name, last_name, username, email, password, channel_count, negative_threshold } | reuse: match username -> id; create: data.client_id |
browseDids | GET /v1/reseller/purchase/dids | query: country_code=+91, `type=<1 TF | 2 Mobile |
lockDid | POST /v1/reseller/purchase/lock-did | { did_ids: [<numeric id>], months } | data.order_id |
addChannel | POST /v1/reseller/purchase/add-channel | { order_id, channel_count, months } | data.channel_total |
orderSummary | GET /v1/reseller/purchase/summary/{order_id} | data.total, data.did_count, data.channel_count | |
confirmOrder | POST /v1/reseller/purchase/confirm | { order_id } | data.purchase_order_id |
mapDidToSubUser | POST /v1/reseller/client/map-did | { client_id, did_id: <numeric id>, user_status: 2, call_recording: 0 } | 200 ok |
renew | POST /v1/reseller/renew/confirm | { did_purchase_ids: [<purchase_order_id>] } | 200 ok |
listPurchased | GET /v1/reseller/did/purchased | optional client_id | rows list |
releaseDid | POST /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 nesteddata. - DID types:
1=Toll-Free,2=Mobile,3=Landlinemap totoll_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 separatedid_number(release). The id is threaded internally asproviderRef(concealed). - confirm returns
purchase_order_id, which renewal consumes not the draftorder_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
| Surface | Mount | Guard |
|---|---|---|
| SuperAdmin | /superadmin/provisioning | verifyJwt + requireRole("superadmin") |
| Org admin | /admin/provisioning | verifyJwt + requireRole("admin") + requireOrg |
| Customer | /provisioning | verifyJwt + requireFeatureAccess("number_purchasing") |
SuperAdmin (/superadmin/provisioning)
| Method | Path | Purpose |
|---|---|---|
| GET | /adapters | Adapter catalog for the provider dropdown |
| GET | /providers | List active providers |
| POST | /providers | Register provider (reactivates an inactive same-name row) |
| PUT | /providers/:providerUID | Update mutable fields |
| POST | /providers/:providerUID/deactivate | Soft-deactivate |
| PUT | /providers/:providerUID/credentials | Store credentials by raw bearer token |
| PUT | /providers/:providerUID/credentials/login | Store credentials via username/password login |
| GET | /providers/:providerUID/credentials | Masked credential view (last-4) |
| GET | /orgs/:orgUID/providers | Providers enabled for an org |
| POST | /orgs/:orgUID/providers/:providerUID/enable | Enable provider for org |
| POST | /orgs/:orgUID/providers/:providerUID/disable | Disable for org (retains owned numbers) |
| PUT | /orgs/:orgUID/rates | Set SuperAdmin margin { providerUID, kind, didType, mode, pct?, flatRate? } |
| GET | /orgs/:orgUID/providers/:providerUID/price-breakdown | Bulk price breakdown (global + per type) |
| POST | /orgs/:orgUID/providers/:providerUID/refresh-costs | Re-capture provider cost via live browse |
| GET | /inventory | Platform-wide inventory (AND filters + pagination) |
Org admin (/admin/provisioning)
| Method | Path | Purpose |
|---|---|---|
| GET | /providers | Providers enabled for the admin's org |
| POST | /customers/:customerUID/enablement | Enable/disable number-buying for a customer |
| PUT | /providers/:providerUID/byo-permission | Toggle BYO permission (default off) |
| PUT | /rates | Set Org margin { providerUID, kind, didType, mode, pct?, flatRate? } |
| GET | /providers/:providerUID/price-breakdown | Org-scoped breakdown (shows final customer price) |
| POST | /providers/:providerUID/refresh-costs | Re-capture provider cost |
| GET | /inventory | Org-scoped inventory (per-customer filter, concealment) |
Customer (/provisioning)
| Method | Path | Purpose |
|---|---|---|
| GET | /numbers/available | Browse Oktivo Numbers (single margin-inclusive price, no provider identity) |
| POST | /numbers/purchase | Buy a DID { did, didType, channels, termMonths, direction?, providerUID? } (providerUID optional; resolved from DID) |
| GET | /numbers/owned | Owned numbers + renewal status |
| POST | /numbers/:mappingUID/release | Release an owned number |
| PUT | /byo/:providerUID/credentials | Configure BYO credentials (customer-owned provider) |
Common status codes
| Code | Meaning |
|---|---|
| 400 | Validation (bad term/channels/didType), rate unavailable |
| 402 | Insufficient wallet balance |
| 403 | Feature/enablement denied, BYO not permitted, provider disabled for org |
| 404 | Provider/customer/mapping not found |
| 409 | Active-DID conflict, idempotency in-progress, provider inactive-on-enable |
| 502 | Provider unreachable (login/release) |
11. UI map (frontend)
| Page | Route | Notes |
|---|---|---|
| Number Management (SuperAdmin) | /superadmin/providers | Tabs: Number Providers (CRUD + credentials) and Org Provisioning (Enablement / Margin table / Inventory) |
| Org Number Provisioning | /admin/provisioning | Providers & enablement, Margin table (final price preview), inventory |
| Oktivo Number (Customer) | /oktivo-numbers | Buy 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 inenv/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_purchasingfeature.
SuperAdmin
- Number Management -> Number Providers -> Add Provider -> pick VoiceLink (Test), enter email/username + password -> Register.
- Org Provisioning -> Enablement -> select org -> toggle provider Enabled (stays on).
- Margin tab -> select org + provider -> Refresh costs (providerCost populates, e.g. 250) -> set Global Percent 30% -> Save (SuperAdmin price = 325).
Org admin
- Number Provisioning -> Providers & Enablement -> provider now listed -> select Customer -> Enable.
- Margin tab -> base SuperAdmin price 325 -> set Percent 20% -> Save (Final Customer Price = 390).
Customer
- Add credits so wallet >= final price x months (e.g. >= 390).
- Oktivo Number -> Buy Numbers -> real mobile DIDs at 390 (no provider identity). If "Rate unavailable": SuperAdmin Margin -> Refresh costs.
- Buy (term 1..60, channels 1..100) -> wallet debited 390 x months.
- My Numbers -> DID shows rate, renewal date, status active, auto-renew on.
- 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
| Symptom | Cause | Fix |
|---|---|---|
| Customer sees "Rate unavailable" | providerCost never captured | Browse once, or SuperAdmin "Refresh costs" |
| Org "No providers enabled" | Provider not enabled for that org | SuperAdmin -> Org Provisioning -> enable |
| Enable toggle stuck off | (fixed) UI read a non-existent field | Reload app |
| "name is already in use" but list empty | (fixed) inactive same-name provider | Re-create now reactivates it |
| Purchase 400 (providerUID) | (fixed) provider now resolved from DID | |
| Numbers empty on customer page | Org not enabled / no margin / no VoiceLink inventory | Check enablement + Refresh costs |