Per-User Outbound Caller ID¶
Each SIP user can present their own DID as caller ID on outbound calls, instead of the org-default. Useful for multi-team orgs where each agent has a dedicated public number.
Shipped 2026-05-23 in PR #291, fixed in PR #296. Operators configure via the editor — Users page → edit user → Outbound Caller ID dropdown.
How it works¶
users.outbound_didcolumn (added long before this feature; never wired) stores the DID number the user presents.- The dialplan generator emits an ODBC lookup in the outbound context, right between the trunk/org concurrent-call enforcement and the existing org-default caller-ID fallback.
- At runtime, the lookup reads the calling extension (channel variable
${EXTENSION}, set by the user's PJSIP endpoint viaset_var=__EXTENSION=<ext>), queriesusers.outbound_didfor that extension, and overridesCALLERID(num)if non-empty.
Caller-ID precedence chain (high to low):
| Source | When it wins |
|---|---|
Per-call Originate(CALLERID=...) | API-originated calls (click-to-call, AI bot outbound) explicitly set it |
Per-route caller_id_override | Outbound route has a hard-coded override |
Per-user outbound_did | The user has one set, all of the above are empty |
Org default DID (did_numbers.is_default=true) | Catch-all when no user override |
| First assigned DID | Fallback when no default DID is marked |
| Softphone-sent CID | No DID configured for the org at all (rare in production) |
Schema¶
No new column — users.outbound_did STRING(20) NULL has existed since before this feature, but was never wired to the dialplan. The User model field:
outbound_did: {
type: DataTypes.STRING(20),
allowNull: true,
comment: "DID this user presents as caller ID on outbound calls. Null = fall back to org default or first assigned DID."
}
Validated at the API: must be a DID assigned to the same org with pool_status='assigned' AND status='active'. Cleared by passing null or "".
ODBC query¶
sip-gateway/cloud-{staging,prod}/func_odbc.conf → [USER_OUTBOUND_DID]:
SELECT outbound_did FROM users
WHERE org_id = ?
AND extension = ?
AND status = 'active'
AND outbound_did IS NOT NULL
AND outbound_did != ''
LIMIT 1
Called from dialplan as ${ODBC_USER_OUTBOUND_DID(<org_id>, <extension>)}. Returns the DID number on match, empty string otherwise.
Dialplan emission¶
Generated in dialplanGenerator.js::generateOutboundContext(). Emitted only when at least one user in the org has a non-empty outbound_did — strict additive, so an org that doesn't use this feature gets byte-identical outbound dialplan.
Helper: orgHasUserWithOutboundDid(org) returns true iff any active user has a non-empty outbound_did. Locked by test UD3 (inactive users with outbound_did don't drive emission — their CALLERID(num) won't match anything at runtime anyway).
The emitted snippet (inside an outbound route, between the cap enforcement and the per-route fallback caller-ID Set):
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)=<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)})
Order matters: the per-user override is set AFTER the per-route fallback (so it wins), but USER_DID is captured BEFORE the fallback overwrites CALLERID(num) (because the lookup reads ${EXTENSION} directly, not CALLERID(num)).
⚠️ The ${EXTENSION} vs ${CALLERID(num)} bug (PR #296)¶
Original PR #291 wrote Set(CALLER_EXT=${CALLERID(num)}). This silently never worked. Symptom: per-user outbound DID set on Manivel (ext 0986) was +918065978003, but every outbound call from his softphone presented the org-default +918065978001.
Root cause: softphones (Zoiper, MicroSIP, Bria, …) send their full SIP username as the From-URI user — org_mna9x47k_0986, not the bare 0986. At the outbound context entry point, ${CALLERID(num)} is org_mna9x47k_0986. The SQL was matching WHERE extension = ? against users.extension which stores bare numbers. No row → empty USER_DID → fallback to org default.
PR #296 changed the lookup to read ${EXTENSION} — an inherited channel variable populated by the user-endpoint config:
[org_mna9x47k_0986]
type=endpoint
...
set_var=__EXTENSION=0986 ; bare extension number, the right value
${EXTENSION} is the bare form, matches users.extension, SQL succeeds.
The double-underscore prefix on __EXTENSION is PJSIP's syntax for inheritable channel variables — value propagates through Local-channel hops and dialplan Goto/Gosub frames.
For API-originated calls (Originate without a user endpoint), __EXTENSION is never set, so ${EXTENSION} is empty, ODBC returns empty, fallback to org-default chain. That's the correct behaviour for those paths — they explicitly set caller_id via the Originate variable.
How to disable for a specific user¶
Editor → Users → Edit → Outbound Caller ID dropdown → "Use org default DID". Server PUT clears the column (outbound_did = null) and triggers autoDeploy. Effective on next outbound call from that user.
Operator-visible side effects of enabling for the first time¶
The PR #294 autoDeploy expansion ensures that setting outbound_did triggers a dialplan regen + reload. Without that wiring, the editor's manual deploy was the only thing that propagated the change — easy to forget.
If a user had outbound_did set in DB from earlier debugging but the editor never showed it / the dialplan never used it, promoting PR #296 to prod made all those previously-dormant DIDs suddenly active. Worth a heads-up to operators: outbound calls from those agents will now present the configured DID, not the org-default. Audit with:
SELECT extension, full_name, outbound_did FROM users
WHERE org_id = '<org_id>'
AND outbound_did IS NOT NULL
AND outbound_did != '';
Tests¶
api/tests/dialplan-generator.test.js — UD1 through UD7. Critical regression test UD5 asserts that orgs with no opted-in user produce byte-identical outbound dialplan to the pre-feature generator. UD6 was updated in PR #296 to lock the ${EXTENSION} form AND assert the old ${CALLERID(num)} form doesn't reappear (test against the implementation can no longer give a false positive).
Related¶
- [[sticky-agent]] — same func_odbc.conf machinery, same migration era
- [[outbound-dialplan-normalization]] (operations) — full picture of outbound context including the
+91country-code normalization - Caller-ID precedence in outbound-caller-id.md — operational runbook for the precedence chain above