Skip to content

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_assigned if 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/:id body: 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 (autoSetDefaultIfFirst in api/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 (requires admin role — owner or manager)
  • Clears is_default on 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..29 passes 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" }
- resolveCallerId returns +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

User ext 1005 dials 9944421125
user.outbound_did = "08065978002"
- 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=1 it's picked ahead of dids[0] via org.dids.find(d => d.is_default)

AI Agent originate

POST /api/v1/calls/originate-to-ai
{ "to": "9944421125" }     (no caller_id)
- resolveCallerId: caller_id undefined → checks is_default DID → picks it - If no is_default: picks first assigned DID - If org has no assigned DID: 400 no_caller_id_available (request rejected)

Data integrity rules

  • Only one is_default=1 per org (enforced in transaction in /set-default)
  • users.outbound_did must match a DID in did_numbers for the user's org with pool_status='assigned' AND status='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.jsresolveCallerId()
Priority resolver (dialplan path) api/src/services/asterisk/dialplanGenerator.jsgenerateUserExtension()
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.jsrouter.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_did column 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=1 on 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-running POST /set-default on 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.