Privacy Numbers Made Easy with JSON-RPC Routing
Ride-hailing apps, delivery platforms, and marketplaces all share a common requirement: connect two parties on a phone call without revealing their real numbers. This is the classic privacy number (AX/NXA) use case.
RustPBX’s SBC addon includes a JSON-RPC router that makes this straightforward. Every incoming call can query your backend API, get the real destination, rewrite the numbers, and connect the call – all in real time.
How Privacy Numbers Work
The basic flow:
- Caller dials a virtual number (e.g.
17005559999) - RustPBX intercepts the INVITE and calls your HTTP API
- Your API looks up the mapping: “17005559999 → real driver number”
- RustPBX rewrites the destination and forwards the call
- The driver sees the virtual number on caller ID, not the rider’s real number
Both parties are protected. Your platform controls the mapping.
Setting Up JSON-RPC Routing
Enable the Feature
Make sure the SBC addon is active (addons = ["sbc"] in config.toml). The JSON-RPC router is built into the SBC addon – no extra installation needed.
Configure via Console
Go to SBC > JSON-RPC Config in the console. You’ll see a rule editor where you define when and how to call your API.

Define a Rule
Each rule has three parts: when to match, what to call, and how to handle the response.
Here’s a rule for the privacy number scenario:
# config/sbc/sbc_jsonrpc.toml
enabled = true
timeout_ms = 3000
[[rules]]
name = "privacy-number-routing"
enabled = true
[rules.match_group]
logic = "all"
[[rules.match_group.conditions]]
field = "Callee"
op = "Regex"
value = "^1700555\\d{4}$"
[rules.upstream]
method = "POST"
url = "https://api.yourplatform.com/telephony/resolve"
body = '''{"virtual_number": "{{ call.callee }}", "call_id": "{{ call.call_id }}", "caller": "{{ call.caller }}"}'''
[rules.upstream.headers]
Authorization = "Bearer your-api-key"
[rules.response]
success_when = "json.action == 'forward'"
callee_rewrite = "{{ json.real_number }}"
caller_rewrite = "{{ json.display_number }}"
[[rules.response.inject_headers]]
op = "Set"
name = "Privacy"
value = "id"
[[rules.response.inject_headers]]
op = "Set"
name = "P-Asserted-Identity"
value = "sip:{{ json.display_number }}@{{ call.caller_host }}"
Let’s break this down.
Match Conditions
The match_group defines which calls trigger this rule:
- Fields: Caller, Callee, CallerUser, CalleeUser, CallerHost, CalleeHost, Direction, CallId, UserAgent, or any SIP header
- Operators: Equals, NotEquals, Contains, StartsWith, EndsWith, Regex, Exists, NotExists
- Logic:
all(AND) orany(OR)
In our example, we match any call where the callee matches ^1700555\d{4}$ – a virtual number range. You can add more conditions like Direction = "Inbound" or specific trunk filtering.
Calling Your API
When a call matches, RustPBX sends an HTTP request to your upstream endpoint:
POST /telephony/resolve HTTP/1.1
Content-Type: application/json
Authorization: Bearer your-api-key
{
"virtual_number": "17005559999",
"call_id": "abc123@192.168.1.1",
"caller": "14155551234"
The template engine (MiniJinja) uses namespaced variables: {{ call.caller }}, {{ call.callee }}, {{ call.call_id }}, {{ call.direction }}, {{ call.caller_user }}, {{ call.callee_user }}, {{ call.caller_host }}, {{ call.callee_host }}, and any custom extractors you define.
Your API responds:
{
"action": "forward",
"real_number": "16505559876",
"display_number": "17005559999",
"max_duration": 3600
}
Response Processing
The response section tells RustPBX how to interpret the API result:
success_when: A boolean expression. Here,json.action == 'forward'means “proceed if the API says forward” (response fields are available asjson.*variables)callee_rewrite: MiniJinja template for the new destination.{{ json.real_number }}maps to the driver’s actual number from the API responsecaller_rewrite: What the driver sees.{{ json.display_number }}shows the virtual number from the API responseinject_headers: AddPrivacy: idandP-Asserted-Identityfor carrier-level privacy
If the API returns anything else (e.g. action: "reject"), the call is rejected with a configurable status code (default 403).
Extractors for Advanced Matching
Sometimes you need to pull data from SIP headers or URIs. Extractors use regex to capture named groups:
[[rules.extractors]]
name = "caller_user"
source = "Caller"
regex = "sip:(?P<caller_user>\\d+)@"
[[rules.extractors]]
name = "account_id"
source = "header:X-Account-Id"
regex = "(?P<account_id>\\d+)"
These extracted values become available in all templates via {{ extracted.caller_user }}, {{ extracted.account_id }}.
Testing in the Console
Before going live, use the Simulate feature:
- Enter a sample caller and callee
- The engine shows which rule matches (if any)
- Displays the upstream request that would be sent
- Shows the expected rewrite result

This lets you verify your match conditions and templates without making real calls.
Multiple Rules
You can chain multiple rules for different scenarios:
- Rule 1: Virtual numbers in
1700555xxxx→ privacy platform API - Rule 2: Toll-free
1800xxxxxxx→ CRM lookup API - Rule 3: Premium rate
1900xxxxxxx→ billing verification API
Rules are evaluated in order. First match wins.
What’s Next?
JSON-RPC routing gives you dynamic, API-driven call control. Combined with the trunk and route infrastructure from the previous post, you have a fully programmable SBC.
In the next post, we’ll look at the operational side: how RustPBX helps with SSL certificates, call record archival, and speech-to-text transcription – the day-to-day tasks that keep a telephony platform running.