Outbound Caller ID resolution¶
How Astradial decides which phone number to present as caller ID on every outbound call. Written after the Apr 17 monorepo cutover.
Priority chain¶
For any outbound call, the caller ID presented to the recipient is resolved in this order. The first rule that produces a valid DID wins:
1. Per-call API caller_id (click-to-call / originate-to-ai requests)
2. Per-user outbound_did (user table column)
3. Org default DID (did_numbers.is_default=1)
4. First assigned DID (ORDER BY number ASC LIMIT 1)
5. NUC range fallback (+918065978000 sentinel — unassigned/reserved, NOT a tenant DID)
Rules 1-4 happen on the cloud (api + dialplan). Rule 5 is the last line of defence on the NUC and catches any caller ID outside the owned Tata range.
1. Per-call API caller_id¶
Applies only to API-initiated calls:
POST /api/v1/calls/click-to-call— body:caller_id: "+918065978003"POST /api/v1/calls/originate-to-ai— body:caller_id: "+918065978003"
The value is validated by resolveCallerId() in api/src/server.js:
SELECT number FROM did_numbers
WHERE org_id=? AND number=?
AND pool_status='assigned' AND status='active'
- 403
caller_id_not_assignedif the DID is not in the calling org's assigned pool (tenant-isolation guarantee) - Normalized to E.164: strips non-digit chars, prepends
+
2. Per-user outbound_did¶
users.outbound_did (VARCHAR(20), nullable). Set via:
- Org admin dashboard → Users → edit a user → "Outbound Caller ID" dropdown
- PUT
/api/v1/users/:idbody:outbound_did: "08065978002"(validated against the org's assigned DIDs)
The column has existed since the early User model but was not wired to the dialplan until PR #291 (2026-05-22), and that initial wiring was broken until PR #296 (2026-05-23). Today there are two distinct paths depending on the user's ring_target:
Softphone agents (ring_target='ext') — PR #291 + #296¶
For SIP users whose calls leave through the outbound trunk via the org's outbound context, the dialplan generator emits a runtime ODBC lookup inside generateOutboundContext():
exten => _X.,n,Set(CALLER_EXT=${EXTENSION})
exten => _X.,n,Set(USER_DID=${ODBC_USER_OUTBOUND_DID(<org_id>,${CALLER_EXT})})
exten => _X.,n,Set(CALLERID(num)=<org-fallback>)
exten => _X.,n,GotoIf($["${USER_DID}"=""]?skip_user_did_<route_id>)
exten => _X.,n,Set(CALLERID(num)=${USER_DID})
exten => _X.,n(skip_user_did_<route_id>),NoOp(Outbound CID = ${CALLERID(num)})
Critical: the lookup reads ${EXTENSION} (the inherited channel variable set by the user-endpoint config's set_var=__EXTENSION=<bare-ext>), NOT ${CALLERID(num)}. Softphones send their full SIP username as From-URI user (org_mna9x47k_0986, not 0986), so reading ${CALLERID(num)} would miss the SQL match every time. The dormant-since-PR-291 bug. See per-user-outbound-did for the full story.
orgHasUserWithOutboundDid() gates emission — orgs with no opted-in user produce byte-identical outbound dialplan. The ODBC query lives at sip-gateway/cloud-{env}/func_odbc.conf → [USER_OUTBOUND_DID].
Phone-target users (ring_target='phone') — earlier path¶
For mobile-callout users whose call leaves the system via a direct trunk Dial inside their user-extension dialplan, the generator inlines the per-user CID at compile time (PJSIP doesn't go through the outbound context here):
exten => 1005,n,Set(CALLERID(num)=${user.outbound_did})
exten => 1005,n,Dial(PJSIP/${phone_number}@trunk,30,tT)
Same outbound_did column, different emission strategy because the dialplan path is different.
3. Org default DID (is_default=1)¶
did_numbers.is_default BOOLEAN. At most one DID per org is marked default.
- Auto-set on first DID assignment to an org (
autoSetDefaultIfFirstinapi/src/routes/didPool.js) - Changeable via: org dashboard → Phone Numbers → DID row dropdown → "Set as Default Caller ID"
- Endpoint:
POST /api/v1/did-pool/:id/set-default(requiresadminrole — owner or manager) - Clears
is_defaulton all other DIDs of the same org in a single transaction - Triggers config regeneration for the org
The dialplan generator picks org.dids.find(d => d.is_default) before falling to dids[0].
4. First assigned DID¶
Pure ordering fallback: ORDER BY number ASC LIMIT 1 with pool_status='assigned' AND status='active'. Used when no per-user and no org default is set.
5. NUC range validation¶
Even after steps 1-4, the caller ID flows to the NUC which does a final sanity check in from-cloud context at /etc/asterisk/extensions.conf:
exten => _X.,1,Set(IN_CID=${FILTER(0-9,${CALLERID(num)})})
; Normalize Indian local (0XXXXXXXXX) -> international (91XXXXXXXXX)
same => n,GotoIf($["${LEN(${IN_CID})}" = "11" & "${IN_CID:0:1}" = "0"]?cid_normalize:cid_skip_norm)
same => n(cid_normalize),Set(IN_CID=91${IN_CID:1})
; Validate against owned Tata range 918065978000-029
same => n(cid_skip_norm),GotoIf($["${IN_CID:0:8}" = "91806597"]?cid_check:cid_default)
same => n(cid_check),GotoIf($[${IN_CID:8} >= 8000 & ${IN_CID:8} <= 8029]?cid_pass:cid_default)
same => n(cid_pass),Set(CALLERID(all)=+${IN_CID})
same => n,Goto(do_dial)
same => n(cid_default),Set(CALLERID(all)=+918065978000) ; fallback sentinel (unassigned, NOT a tenant DID)
Behaviour:
- Indian local format (
08065978002, 11 digits starting 0) is auto-promoted to international (918065978002) before the range check - Anything in
+91 8065 9780 00..29passes through unchanged - Anything else (extensions like
1005, mobiles, spoofed numbers) falls back to the sentinel+918065978000
Fallback sentinel must never be a live/routable tenant DID
The fallback was +918065978001 until 2026-06 — but 78001 is AstraPrivate's live staging DID (routing_environment='staging'), so a recipient who saw the fallback CID and called it back was routed into the AstraPrivate staging/test org, not their own org. For a hospital that means a patient's callback reached a test softphone, answered by no clinician.
The fallback is now +918065978000, a dedicated unassigned sentinel (pool_status='reserved', no owning org). A callback to it hits tata-did-route's _X. catch-all → Playback(number-not-in-service) → Hangup() — safe and contained, never a tenant/test org. Rule: the fallback sentinel must always be an unassigned, reserved DID — never reuse a real tenant's number. See Error 33.
Example walkthroughs¶
Click-to-call with explicit caller_id¶
POST /api/v1/calls/click-to-call
{ "from": "1005", "to": "9944421125", "caller_id": "+918065978003" }
+918065978003 (validated) - Originate with CallerID: +918065978003 - Dialplan doesn't override (org default path skipped because CID is already set) - NUC: IN_CID=918065978003 → in range → passes as +918065978003 - Recipient sees +918065978003 ✅ Softphone outbound from user ext 1005 (GrandEstancia) with per-user DID set¶
- Dialplan:Set(CALLERID(num)=08065978002) before Dial(PJSIP/...@trunk) - Reaches NUC with CALLERID(num)=08065978002 - NUC: length=11, starts with 0 → normalize to 918065978002 - Range check: 91806597 prefix ✅, 8002 in 8000-8029 ✅ - Passes as +918065978002 - Recipient sees +918065978002 (Indian carriers display as 08065978002) Softphone outbound from user with no outbound_did¶
- Dialplan uses
org.dids[0]→ org's first assigned DID - If that DID is
is_default=1it's picked ahead of dids[0] viaorg.dids.find(d => d.is_default)
AI Agent originate¶
- resolveCallerId: caller_id undefined → checks is_default DID → picks it - If no is_default: picks first assigned DID - If org has no assigned DID: 400no_caller_id_available (request rejected) Data integrity rules¶
- Only one
is_default=1per org (enforced in transaction in/set-default) users.outbound_didmust match a DID indid_numbersfor the user's org withpool_status='assigned'ANDstatus='active'(enforced on PUT)- DIDs outside the Tata owned range (not matching
91806597800-829) always hit NUC fallback
Where each piece lives¶
| Concern | File / table |
|---|---|
| Priority resolver (API path) | api/src/server.js → resolveCallerId() |
| Priority resolver (dialplan path) | api/src/services/asterisk/dialplanGenerator.js → generateUserExtension() |
| Per-user DID column | users.outbound_did (VARCHAR) |
| Per-org default DID flag | did_numbers.is_default (BOOLEAN) |
| Validation on API PUT | api/src/server.js PUT /api/v1/users/:id |
| Set default endpoint | api/src/routes/didPool.js → router.post('/:id/set-default') |
| NUC range check | /etc/asterisk/extensions.conf on NUC, [from-cloud] context |
Known edge cases¶
- DID deleted or released while user references it: dialplan still emits the old value. Next config deploy regenerates without the stale reference but
users.outbound_didcolumn keeps pointing at a now-missing DID. Cleanup:UPDATE users SET outbound_did=NULL WHERE outbound_did NOT IN (SELECT number FROM did_numbers WHERE org_id=users.org_id). - Multiple
is_default=1on the same org: shouldn't happen (transaction guards the set-default endpoint), but if it does from a hand-edited DB,find(d => d.is_default)picks the first in result order. Fix by re-runningPOST /set-defaulton the intended DID. - Caller ID outside owned range bypassing NUC: impossible — NUC always applies the range check in
from-cloud. If someone bypasses NUC (direct Tata trunk?), Tata would reject the call anyway.