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         (+918065978001 default)

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 dialplan generator emits this for softphone outbound calls (ring_target=phone users):

exten => 1005,n,Set(CALLERID(num)=${user.outbound_did})
exten => 1005,n,Dial(PJSIP/${phone_number}@trunk,30,tT)

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)=+918065978001)   ; fallback

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 +918065978001

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.