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_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 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 (
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)=+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..29passes 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" }
+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.