Skip to content

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

  1. users.outbound_did column (added long before this feature; never wired) stores the DID number the user presents.
  2. 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.
  3. At runtime, the lookup reads the calling extension (channel variable ${EXTENSION}, set by the user's PJSIP endpoint via set_var=__EXTENSION=<ext>), queries users.outbound_did for that extension, and overrides CALLERID(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).

  • [[sticky-agent]] — same func_odbc.conf machinery, same migration era
  • [[outbound-dialplan-normalization]] (operations) — full picture of outbound context including the +91 country-code normalization
  • Caller-ID precedence in outbound-caller-id.md — operational runbook for the precedence chain above