SBC Routing Configuration Library
Find the routing problem you need to solve on ProSBC, copy the Ruby configuration that solves it, and adapt it to your network. You can test every one free on ProSBC Lab before it goes anywhere near live calls.
How these configurations work
The library covers twelve decisions operators automate at the session border: least-cost routing, carrier failover, toll-fraud controls, rate limits, header and number normalization, per-call STIR/SHAKEN attestation and external API lookups. Each configuration states the problem, the approach and what to watch out for, since the failure modes are what you need to know before you deploy.
Routing is written as code here rather than as form fields because a configuration form can only express what whoever built it thought to anticipate, which is fine until the day your routing depends on something they did not. ProSBC exposes routing as Ruby instead, so a decision can look at anything available at call setup: the current hour, the live call count on each carrier, the answer from an external service, or whether the calling number is one you issued.
Two of these are complete routing scripts that subclass BaseRouting. The other ten are filter modules, which is how ProSBC’s own routing modules are packaged: you import the file under Routing Scripts, then add a require, an include and one filter line to your main script, usually simple_routing.rb. Each file’s header lists those lines and every NAP or route column it reads. They follow base_routing 1.42b (behavior version 2), so check your version if you run an older release.
The configurations
You can filter the list by what you’re trying to fix. Click Copy on any configuration to grab the whole file, or use its link to send a colleague straight to it.
12 configurations across 6 categories.
Least-cost routing with time-of-day rates
- Problem
- You buy termination from several carriers whose rates change by hour, and you want each call to take the cheapest route available at that moment.
- Approach
- Store each carrier’s rates in custom route columns, then supply an ordering method that sorts the matching routes by the rate for the current hour. Matching and remapping stay standard.
- Watch out for
- The hour is checked on every call, so the switch from peak to off-peak rates happens on the first call after the boundary. The rates themselves are route columns, read when the configuration is activated, so changing a rate means editing the route and activating again. A route with no cost columns sorts last, which makes it a natural fallback: keep one, so a carrier with missing rates cannot strand a call.
require 'base_routing'
# Least-cost routing with time-of-day rates. A complete routing script.
#
# Each route needs three custom route columns:
# cost_offpeak : rate applied 00:00-06:59
# cost_peak : rate applied 07:00-18:59
# cost : rate for all other hours, and the fallback
# Hours are the SBC's local time.
class LeastCostRouting < BaseRouting
route_match :call_field_name => :called
route_match :call_field_name => :nap
route_remap :call_field_name => :called, :route_field_name => :remapped_called
route_remap :call_field_name => :nap, :route_field_name => :remapped_nap
route_order :method => :order_by_current_cost
# Called for every call with the matching routes. Returns them cheapest first.
def order_by_current_cost(routes, nap_list)
column = rate_column_for(Time.now.hour)
routes.sort_by { |route| cost_of(route, column) }
end
private
def rate_column_for(hour)
case hour
when 0..6 then :cost_offpeak
when 7..18 then :cost_peak
else :cost
end
end
# A route with no usable cost sorts last instead of sorting as zero,
# which would make it look like the cheapest route.
def cost_of(route, column)
raw = route[column] || route[:cost]
raw.nil? ? Float::INFINITY : raw.to_f
end
end
@@routing = LeastCostRouting.new
def init_routes(routes)
@@routing.init routes
end
def route(call, nap_list)
@@routing.route call, nap_list
end
Carrier failover on 503 and timeout
- Problem
- When a carrier returns 503 Service Unavailable or stops responding, calls fail instead of trying the next carrier.
- Approach
- Return several routes in priority order and let ProSBC’s route retry work through them. Which responses move a call on to the next route is set per cause in the profile’s Reason Cause Mapping rather than in the script, so the script orders the candidates and caps how many a call may try.
- Watch out for
- Be deliberate about which causes continue. 503 and 408 are safe to retry, whereas 404, 486 and the 6xx class are real answers about the destination, so retrying them on another carrier wastes attempts and can reach the same destination twice. Check 603 in particular, because the default profile sets it to Continue call. A carrier that never answers is caught by the route retry timeout, which you can set globally, per NAP or per route. If you are unsure what a code means, look it up in the SIP response code decoder.
require 'base_routing'
# Priority routing with a capped route-retry list. A complete routing script.
#
# Which SIP responses move a call on to the next route is not decided in the
# script. It is the "Route retry action" of each cause in the profile's
# Reason Cause Mapping (Profiles > Edit Reason Cause Mapping):
# Continue call : 408, 500, 502, 503, 504 (this carrier could not take it)
# Stop call : 404, 484, 486, 6xx (a real answer about the destination)
# A carrier that never answers is caught by the route retry timeout
# (route_retry_mode and route_retry_timeout, set globally, per NAP or per route).
#
# Route column 'priority' (integer): 0 is tried first. An empty priority also
# counts as 0, so set it on every route.
class FailoverRouting < BaseRouting
MAX_ATTEMPTS = 3
route_match :call_field_name => :called
route_match :call_field_name => :nap
route_remap :call_field_name => :called, :route_field_name => :remapped_called
route_remap :call_field_name => :nap, :route_field_name => :remapped_nap
route_order :route_field_name => :priority
after_filter :method => :cap_attempts
# ProSBC tries the returned routes in order. Returning only the first
# MAX_ATTEMPTS stops a bad prefix from walking the whole carrier list.
def cap_attempts(params)
params[:routes] = params[:routes].first(MAX_ATTEMPTS)
params
end
end
@@routing = FailoverRouting.new
def init_routes(routes)
@@routing.init routes
end
def route(call, nap_list)
@@routing.route call, nap_list
end
Skip carriers that are at their session limit
- Problem
- Calls are offered to a carrier that has no capacity left, producing 503s that could have been avoided.
- Approach
- After matching, drop every route whose destination NAP is at or near its contracted limit, using the NAP’s live outgoing call count, so a saturated carrier is never offered a call.
- Watch out for
- Leave headroom rather than filling to the contracted limit exactly, because a carrier that counts sessions slightly differently will reject calls you believe are within budget. ProSBC NAPs can also enforce a hard maximum of simultaneous calls on their own, and this filter is for stepping aside before that point, with a margin you choose. For the wider picture, see these VoIP failover strategies.
# Skip destination carriers that are at, or close to, their session limit.
#
# Import this file under Routing Scripts (Load at Startup unchecked), then add
# to your main routing script (usually simple_routing.rb):
# require 'capacity_headroom'
# include CapacityHeadroom # inside the routing class
# after_filter :method => :skip_full_carriers
#
# NAP column (Create New NAP Column):
# max_outgoing_call : integer, the carrier's contracted session limit.
# Leave it empty on NAPs that have no limit.
module CapacityHeadroom
HEADROOM = 0.95 # treat 95% of the limit as full
# Called once, when the configuration is activated.
def init_skip_full_carriers(params)
log_trace :always, "Using CapacityHeadroom (full at #{(HEADROOM * 100).round}% of max_outgoing_call)"
end
# Called for every call, after matching. Keeps only routes whose
# destination NAP still has room, using the NAP's live call count.
def skip_full_carriers(params)
naps = params[:naps]
params[:routes] = params[:routes].select do |route|
nap = naps[route[:remapped_nap].to_s.to_sym]
limit = nap ? nap[:max_outgoing_call].to_i : 0
limit <= 0 || nap[:inst_outgoing_call_cnt].to_i < limit * HEADROOM
end
raise RoutingException, :no_circuit_available if params[:routes].empty?
params
end
end
Block calling numbers from a maintained list
- Problem
- You need to stop calls from specific calling numbers or prefixes: a mandatory blocking order, a fraud pattern, or a known abusive source.
- Approach
- Load the list once, when the configuration is activated, then reject matching calls before any routing work happens. Entries match as prefixes as well as exact numbers, so one entry can block a whole range.
- Watch out for
- Load at activation, not per call. A file read on every INVITE will not survive real call rates, and updating the list then means importing the new file and activating the configuration. Log every block with the entry it matched, so you can show why a call was rejected. The rejection reason becomes a SIP response through the profile’s Reason Cause Mapping, so check what
call_rejectedmaps to there, and keep policy blocks apart from authorization failures so your CDRs stay readable. For blocking orders that name providers, see the robocall enforcement tracker.
# Reject calls whose calling number matches a blocklist entry.
#
# Import this file under Routing Scripts (Load at Startup unchecked), then add
# to your main routing script (usually simple_routing.rb):
# require 'calling_blocklist'
# include CallingBlocklist # inside the routing class
# before_filter :method => :reject_blocked_calling, :blocklist_file => 'blocklist.csv'
#
# blocklist.csv is imported as a custom file in the File DB. First line is the
# header; each entry is a full number or a prefix, with or without '+':
# prefix,reason
# 15551230000,blocking order 2026-014
# 1555999,fraud pattern
module CallingBlocklist
# Called once, when the configuration is activated, so no file is read on
# the call path. DbFile.get_file raises if the file has not been imported.
def init_reject_blocked_calling(params)
@blocked = {}
file = DbFile.get_file(params[:blocklist_file] || 'blocklist.csv')
file.csv_parse do |col_names, col_vals, row_idx|
prefix = col_vals['prefix'].to_s.strip.sub(/\A\+/, '')
@blocked[prefix] = col_vals['reason'].to_s unless prefix.empty?
end
log_trace :always, "CallingBlocklist loaded #{@blocked.size} entries"
end
# Called for every call, before any routing work. Checks each leading part
# of the number against the table, so the cost does not grow with the list.
def reject_blocked_calling(params)
calling = params[:call][:calling].to_s.sub(/\A\+/, '')
hit = (1..calling.length).map { |n| calling[0, n] }.find { |prefix| @blocked.key?(prefix) }
if hit
log_trace 1, "Blocked call from #{calling}: matched #{hit} (#{@blocked[hit]})"
raise RoutingException, :call_rejected
end
params
end
end
Restrict expensive destinations by time and trunk
- Problem
- Toll fraud typically shows up as international calls to high-cost destinations, outside business hours, from a compromised extension or PBX.
- Approach
- Give each trunk a NAP column listing the country codes it may reach, and narrow high-risk destinations to weekday business hours. The check runs before routing, so a compromised endpoint cannot generate cost.
- Watch out for
- This is the single highest-value fraud control you can add, because it caps exposure regardless of how the credential was compromised. Default-deny international and allow-list the destinations each customer genuinely calls. Country code 1 also reaches the Caribbean NANP countries, which bill at international rates, so the example list names area codes such as 876 and 809 explicitly. Most toll fraud lands overnight and at weekends, which is exactly when nobody is watching. More on real-time toll fraud prevention.
# Per-trunk destination allow-list, tightened outside business hours.
#
# Import this file under Routing Scripts (Load at Startup unchecked), then add
# to your main routing script (usually simple_routing.rb):
# require 'destination_policy'
# include DestinationPolicy # inside the routing class
# before_filter :method => :enforce_destination_policy
#
# NAP column (Create New NAP Column), read on the incoming NAP:
# allowed_cc : country codes the trunk may call, space separated ('1 44 33'),
# or 'any'. Empty means '1' (domestic only, for a NANP operator).
#
# Called numbers are expected in E.164, with or without '+', at this point.
module DestinationPolicy
# Example list, replace it with your own. Includes Caribbean NANP area codes,
# which country code 1 also reaches. Allowed only Monday to Friday,
# 08:00-18:59, SBC local time.
HIGH_RISK = %w[1876 1809 212 224 225 234 235 252 372 373 508 509 675 676 677 678 679].freeze
BUSINESS_DAYS = (1..5) # Time#wday: 0 is Sunday
BUSINESS_HOURS = (8..18)
def init_enforce_destination_policy(params)
log_trace :always, "Using DestinationPolicy (#{HIGH_RISK.size} high-risk prefixes)"
end
def enforce_destination_policy(params)
call = params[:call]
nap = params[:naps][call[:nap].to_s.to_sym] || {}
called = call[:called].to_s.sub(/\A\+/, '')
allowed = nap[:allowed_cc].to_s.split
allowed = ['1'] if allowed.empty?
unless allowed.include?('any') || allowed.any? { |cc| called.start_with?(cc) }
log_trace 1, "Denied #{called} on #{call[:nap]}: destination not permitted"
raise RoutingException, :call_rejected
end
now = Time.now
in_hours = BUSINESS_DAYS.cover?(now.wday) && BUSINESS_HOURS.cover?(now.hour)
if HIGH_RISK.any? { |cc| called.start_with?(cc) } && !in_hours
log_trace 1, "Denied #{called} on #{call[:nap]}: high-risk destination out of hours"
raise RoutingException, :call_rejected
end
params
end
end
Throttle call attempts per calling number
- Problem
- One calling number starts placing calls faster than any person can dial, from a looping dialer or a hijacked extension, while the rest of its trunk is normal. A per-trunk cap either misses it or slows every other customer on that trunk.
- Approach
- Keep a one-second sliding window of attempts per calling number and reject attempts past the limit, so one source is slowed without touching its neighbors.
- Watch out for
- For a plain per-trunk limit, use the NAP’s built-in call rate limiting (a maximum calls per second plus a burst allowance) rather than a script, and keep this for limits the NAP setting cannot express. The window lives in the script’s memory, so it starts empty whenever the configuration is activated, and idle numbers are pruned so the table cannot grow without bound. Floods that do not come from a single number need SIP DoS protection at the SBC.
# Sliding-window limit on call attempts per calling number.
#
# For a plain per-trunk limit, use the NAP's own call rate limiting
# (maximum calls per second and maximum burst) instead of a script.
#
# Import this file under Routing Scripts (Load at Startup unchecked), then add
# to your main routing script (usually simple_routing.rb):
# require 'calling_rate_limit'
# include CallingRateLimit # inside the routing class
# before_filter :method => :limit_calling_rate
module CallingRateLimit
MAX_ATTEMPTS = 5 # attempts allowed per calling number...
WINDOW = 1.0 # ...in this many seconds
PRUNE_EVERY = 1000 # calls between clean-ups of idle numbers
# Called when the configuration is activated, so the window starts empty.
def init_limit_calling_rate(params)
@attempts = {}
@calls = 0
log_trace :always, "Using CallingRateLimit (#{MAX_ATTEMPTS} per #{WINDOW}s per calling number)"
end
def limit_calling_rate(params)
calling = params[:call][:calling].to_s
return params if calling.empty?
now = Time.now.to_f
recent = (@attempts[calling] ||= [])
recent.reject! { |t| now - t > WINDOW }
if recent.size >= MAX_ATTEMPTS
log_trace 1, "Rate limit hit for #{calling}: #{recent.size} attempts in #{WINDOW}s"
raise RoutingException, :no_circuit_available
end
recent << now
@calls += 1
prune_idle(now) if @calls % PRUNE_EVERY == 0
params
end
private
# Forget numbers with no attempt inside the window, so the table stays small.
def prune_idle(now)
@attempts.delete_if { |number, times| times.empty? || now - times.last > WINDOW }
end
end
Normalize P-Asserted-Identity per carrier
- Problem
- One carrier wants the calling identity in P-Asserted-Identity, another only reads From, and a third rejects the call if PAI is present at all.
- Approach
- Read a policy from a column on each destination NAP: build a PAI from the calling number where the carrier requires one, remove it where the carrier objects, and pass it through everywhere else.
- Watch out for
- Get this right before you troubleshoot attestation problems. The signing service takes the calling identity from PAI when one is present, so a malformed PAI shows up as what looks like an attestation fault. ProSBC presents PAI to the script as the
private_addressfields rather than as a raw header, which is why this edits those fields. More on SIP header manipulation.
# Per-carrier P-Asserted-Identity policy.
#
# Import this file under Routing Scripts (Load at Startup unchecked), then add
# to your main routing script (usually simple_routing.rb):
# require 'pai_policy'
# include PaiPolicy # inside the routing class
# after_remap_filter :method => :apply_pai_policy
#
# NAP columns (Create New NAP Column), read on the destination NAP:
# pai_policy : build|strip|pass. Empty means pass.
# build : assert the calling number in P-Asserted-Identity
# strip : send no P-Asserted-Identity or P-Preferred-Identity
# pass : forward whatever arrived
# pai_domain : optional host for the built identity, e.g. carrier.example.net
module PaiPolicy
def init_apply_pai_policy(params)
log_trace :always, "Using PaiPolicy"
end
# Called once per call, after remapping. out_calls[i] is the outgoing call
# that will be made on routes[i], so each carrier gets its own policy.
def apply_pai_policy(params)
params[:routes].each_with_index do |route, i|
out_call = params[:out_calls][i]
nap = params[:naps][route[:remapped_nap].to_s.to_sym] || {}
case nap[:pai_policy].to_s.downcase
when 'build'
calling = out_call[:calling].to_s
next if calling.empty?
out_call[:private_address] = calling
out_call[:private_address_sip_host] = nap[:pai_domain] unless nap[:pai_domain].to_s.empty?
log_trace 2, "PAI built for #{route[:remapped_nap]}: #{calling}"
when 'strip'
out_call[:private_address] = ''
out_call[:preferred_id] = ''
log_trace 2, "PAI stripped for #{route[:remapped_nap]}"
end
end
params
end
end
Add user=phone for carriers that require it
- Problem
- A carrier rejects or misroutes calls unless the SIP URIs carry user=phone, while your other carriers neither need nor want it.
- Approach
- After remapping, add user=phone to the From, To and P-Asserted-Identity URI parameters on calls to the carriers flagged in a NAP column, keeping every parameter already there.
- Watch out for
- ProSBC hands the script each header’s parameters as three strings (user, URI and header parameters), so this appends to the URI parameters instead of rebuilding the header. Headers the SIP stack processes itself, such as Require, Supported and Privacy, never appear in the script’s custom header field, so a routing script cannot rewrite them as raw headers. The script parameter reference lists the fields a script can change.
# Add user=phone to the From, To and P-Asserted-Identity URIs, per carrier.
#
# Import this file under Routing Scripts (Load at Startup unchecked), then add
# to your main routing script (usually simple_routing.rb):
# require 'user_phone'
# include UserPhone # inside the routing class
# after_remap_filter :method => :add_user_phone
#
# NAP column (Create New NAP Column), read on the destination NAP:
# add_user_phone : boolean. Set it on the carriers that require user=phone.
module UserPhone
# SIP header parameter fields: From, To and P-Asserted-Identity.
FIELDS = [:calling_parameters, :called_parameters, :private_address_parameters].freeze
def init_add_user_phone(params)
log_trace :always, "Using UserPhone"
end
def add_user_phone(params)
params[:routes].each_with_index do |route, i|
nap = params[:naps][route[:remapped_nap].to_s.to_sym] || {}
next unless route_col_true?(nap[:add_user_phone])
out_call = params[:out_calls][i]
FIELDS.each do |field|
next if field == :private_address_parameters && out_call[:private_address].to_s.empty?
# Each field is a hash of :user_param, :uri_param and :header_param strings.
sip_params = out_call[field].is_a?(Hash) ? out_call[field] : {}
uri = sip_params[:uri_param].to_s
next if uri.split(';').include?('user=phone')
sip_params[:uri_param] = uri.empty? ? 'user=phone' : "#{uri};user=phone"
out_call[field] = sip_params
end
log_trace 2, "user=phone added for #{route[:remapped_nap]}"
end
params
end
end
Normalize every number to E.164 on egress
- Problem
- 404 Not Found or 484 Address Incomplete, because one carrier wants +1XXXXXXXXXX, another 1XXXXXXXXXX and a third the bare 10 digits.
- Approach
- Canonicalize each called and calling number to E.164 internally, then format it on egress the way each destination NAP’s column says.
- Watch out for
- Normalize in, format out. One canonical internal representation is what keeps this maintainable as trunks are added, while translating directly between carrier formats produces a rule matrix that grows quadratically. Anything that is not a number, such as anonymous, is left alone. More on the E.164 number format.
# Canonicalize numbers to E.164, then format them the way each carrier expects.
#
# Import this file under Routing Scripts (Load at Startup unchecked), then add
# to your main routing script (usually simple_routing.rb):
# require 'e164_format'
# include E164Format # inside the routing class
# after_remap_filter :method => :format_numbers
#
# NAP column (Create New NAP Column), read on the destination NAP:
# number_format : plus|cc|nsn. Empty means plus.
# plus -> +15551234567
# cc -> 15551234567
# nsn -> 5551234567 (national significant number)
module E164Format
DEFAULT_CC = '1'
def init_format_numbers(params)
log_trace :always, "Using E164Format (default country code #{DEFAULT_CC})"
end
# Runs after the route's own remapping, once per outgoing call.
def format_numbers(params)
params[:routes].each_with_index do |route, i|
out_call = params[:out_calls][i]
nap = params[:naps][route[:remapped_nap].to_s.to_sym] || {}
style = nap[:number_format].to_s.downcase
style = 'plus' if style.empty?
[:called, :calling].each do |field|
e164 = e164_canonical(out_call[field])
out_call[field] = e164_formatted(e164, style) unless e164.empty?
end
end
params
end
private
# Digits only, with the country code present. Anything that is not a
# number (anonymous, alphanumeric) comes back empty and is left alone.
def e164_canonical(number)
digits = number.to_s.gsub(/[^0-9+]/, '')
return '' if digits.delete('+').empty?
if digits.start_with?('+')
digits[1..-1]
elsif digits.start_with?('011') # NANP international prefix
digits[3..-1]
elsif digits.length == 10 # bare NANP national number
DEFAULT_CC + digits
else
digits
end
end
def e164_formatted(e164, style)
case style
when 'plus' then "+#{e164}"
when 'nsn' then e164.start_with?(DEFAULT_CC) ? e164[DEFAULT_CC.length..-1] : e164
else e164
end
end
end
Choose attestation A, B or C per call
- Problem
- One SBC carries retail, wholesale and gateway traffic. A single per-trunk attestation setting is either a false claim on some calls or an unnecessary downgrade on others.
- Approach
- Decide the level from the ingress trunk’s traffic class and whether the calling number is one you issued to that customer, then send the decision only on the INVITE to the signing service, stripping any level a customer tried to send.
- Watch out for
- Attesting at A level for a number you cannot verify is a false claim under FCC rules, and attesting C for your own retail subscribers hurts their call completion. The decision has to be yours rather than your signing vendor’s, and FCC 24-120 makes that explicit. How the level reaches the signing service is provider-specific, so the header name in the file is a placeholder to replace with the one your STI-AS documents. In production, ProSBC reaches its signing partners, TransNexus ClearIP and Neustar, over SIP, which is why this keys off the NAP whose
service_typeis AUTHENTICATION.
# Decide attestation A, B or C per call, and pass the decision to the signing
# service on the INVITE that goes to it.
#
# Import this file under Routing Scripts (Load at Startup unchecked), then add
# to your main routing script (usually simple_routing.rb):
# require 'attestation_policy'
# include AttestationPolicy # inside the routing class
# after_remap_filter :method => :select_attestation
#
# NAP columns (Create New NAP Column):
# traffic_class : retail|wholesale|gateway on each incoming NAP. Empty means gateway.
# service_type : NORMAL|AUTHENTICATION|VERIFICATION, as in the ClearIP setup.
# The level is sent only to NAPs marked AUTHENTICATION.
#
# issued_numbers.csv is imported as a custom file in the File DB:
# prefix,nap
# 1555200,retail-a
# 1555300,retail-b
module AttestationPolicy
# PLACEHOLDER. Use the header name and values your signing service documents
# for the requested attestation level.
ATTEST_HEADER = 'X-Attestation-Level'
def init_select_attestation(params)
@issued = {}
file = DbFile.get_file(params[:issued_file] || 'issued_numbers.csv')
file.csv_parse do |col_names, col_vals, row_idx|
prefix = col_vals['prefix'].to_s.strip.sub(/\A\+/, '')
(@issued[col_vals['nap'].to_s] ||= []) << prefix unless prefix.empty?
end
log_trace :always, "AttestationPolicy loaded number ranges for #{@issued.size} NAPs"
end
def select_attestation(params)
call = params[:call]
ingress = params[:naps][call[:nap].to_s.to_sym] || {}
calling = call[:calling].to_s.sub(/\A\+/, '')
level =
case ingress[:traffic_class].to_s.downcase
when 'retail' then issued_to?(call[:nap].to_s, calling) ? 'A' : 'B'
when 'wholesale' then 'B'
else 'C'
end
params[:routes].each_with_index do |route, i|
out_call = params[:out_calls][i]
dest = params[:naps][route[:remapped_nap].to_s.to_sym] || {}
# Never forward a level that arrived from the customer.
headers = out_call[:sip_header].to_s.split("\n").reject { |h| attest_header?(h) }
headers << "#{ATTEST_HEADER}: #{level}" if dest[:service_type].to_s.upcase == 'AUTHENTICATION'
out_call[:sip_header] = headers.empty? ? '' : headers.join("\n") + "\n"
end
log_trace 2, "Attestation #{level} for #{calling} from #{call[:nap]}"
params
end
private
def issued_to?(nap_name, calling)
(@issued[nap_name] || []).any? { |prefix| calling.start_with?(prefix) }
end
def attest_header?(line)
line.split(':', 2).first.to_s.strip.casecmp(ATTEST_HEADER) == 0
end
end
Route on the verification result (verstat)
- Problem
- Inbound calls arrive with a verification result and you want to treat verified, failed and unsigned traffic differently instead of ignoring it.
- Approach
- Read verstat from the P-Asserted-Identity or From URI parameters, then send failed calls to routes flagged for them, such as an announcement or a review queue, and let everything else route normally.
- Watch out for
- The distinction that matters is between TN-Validation-Failed and No-TN-Validation. Failed means a token was checked and did not pass, while No-TN-Validation means there was nothing to check, usually because the call arrived unsigned, and treating the two alike would flag enormous volumes of legitimate traffic. With TransNexus ClearIP as the verification service, verstat comes back in the P-Asserted-Identity of its 302 response. The PASSporT decoder sets out what each verstat value means.
# Route inbound calls on the STIR/SHAKEN verification result (verstat).
#
# Import this file under Routing Scripts (Load at Startup unchecked), then add
# to your main routing script (usually simple_routing.rb):
# require 'verstat_policy'
# include VerstatPolicy # inside the routing class
# after_filter :method => :branch_on_verstat
#
# Route column (Create New Route Column):
# failed_verification : boolean. Set it on the route(s) that should take calls
# whose verification failed, such as an announcement
# or a review queue. Other calls never use those routes.
module VerstatPolicy
def init_branch_on_verstat(params)
log_trace :always, "Using VerstatPolicy"
end
def branch_on_verstat(params)
call = params[:call]
verstat = read_verstat(call)
divert = params[:routes].select { |route| route_col_true?(route[:failed_verification]) }
normal = params[:routes] - divert
if verstat == 'TN-Validation-Failed' && !divert.empty?
# A token was present and did not validate. Divert rather than drop.
log_trace 1, "Verification failed for #{call[:calling]}: diverting"
params[:routes] = divert
else
# Passed, No-TN-Validation or no verstat at all. Unsigned is not
# suspicious on its own, so normal policy decides.
log_trace 2, "verstat #{verstat || 'absent'} for #{call[:calling]}"
params[:routes] = normal
end
params
end
private
# verstat is a parameter of the P-Asserted-Identity or From URI. Each
# parameter field is a hash of :user_param, :uri_param and :header_param.
def read_verstat(call)
[:private_address_parameters, :calling_parameters].each do |field|
value = call[field]
text = value.is_a?(Hash) ? value.values.join(';') : value.to_s
text.split(';').each do |pair|
name, setting = pair.split('=', 2)
return setting.to_s.strip if name.to_s.strip.casecmp('verstat') == 0
end
end
nil
end
end
Query an external API and route on the answer
- Problem
- Routing depends on data the SBC does not hold: an LNP dip, a fraud score, a customer’s current plan, or a CRM lookup.
- Approach
- Use ProSBC’s HTTP query. The script describes the request and raises
http_query_required, ProSBC runs it with a hard timeout, and the script runs again with the response, which it uses to remap the called number, reject the call, or put the carrier the service named first. - Watch out for
- The failure policy is the whole design. The script never waits on the network itself, but the call does, so the timeout has to fit inside your post-dial delay budget. Decide in advance what a slow or failed lookup means: fail open for routing enrichment, fail closed for fraud blocking, and make the choice explicit in the filter options.
# Ask an external service how to route a call, using ProSBC's HTTP query.
# The script does not wait on the network itself: it describes the query,
# ProSBC runs it, and the routing script runs again with the answer.
#
# Import this file under Routing Scripts (Load at Startup unchecked), then add
# to your main routing script (usually simple_routing.rb):
# require 'external_route_lookup'
# include ExternalRouteLookup # inside the routing class
# before_filter :method => :external_route_lookup,
# :lookup_url => 'https://routing.example.com/v1/lookup',
# :timeout_ms => 1500,
# :fail_open => true
# after_filter :method => :prefer_lookup_nap
#
# Expected JSON answer: {"called": "...", "nap": "...", "block": false, "reason": "..."}
require 'uri'
require 'json'
module ExternalRouteLookup
def init_external_route_lookup(params)
@lookup_url = params[:lookup_url].to_s
@timeout_ms = (params[:timeout_ms] || 1500).to_i # keep well inside your post-dial delay budget
@fail_open = params.key?(:fail_open) ? params[:fail_open] : true
log_trace :always, "Using ExternalRouteLookup: #{@lookup_url} (#{@timeout_ms} ms, fail #{@fail_open ? 'open' : 'closed'})"
end
def external_route_lookup(params)
call = params[:call]
if params[:http_query].nil?
# First pass: describe the query and ask ProSBC to run it.
query = URI.encode_www_form(:called => call[:called].to_s, :calling => call[:calling].to_s)
params[:http_query] = {
:url => "#{@lookup_url}?#{query}",
:use_post => false,
:timeout_ms => @timeout_ms,
:headers_hash => { 'Accept' => 'application/json' }
}
raise RoutingException, :http_query_required
end
# Second pass: the result is in params[:http_query].
answer = parse_answer(params[:http_query])
if answer.nil?
log_trace 1, "Routing lookup failed: #{@fail_open ? 'routing normally' : 'rejecting'}"
raise RoutingException, :temporary_failure unless @fail_open
return params
end
if answer['block']
log_trace 1, "Routing lookup blocked #{call[:called]}: #{answer['reason']}"
raise RoutingException, :call_rejected
end
call[:called] = answer['called'].to_s if answer['called']
if answer['nap']
params[:user_context] ||= {}
params[:user_context][:lookup_nap] = answer['nap'].to_s
end
params
end
# After matching: try the NAP the service named first, keep the rest as backup.
def prefer_lookup_nap(params)
wanted = (params[:user_context] || {})[:lookup_nap]
return params if wanted.nil?
first = params[:routes].select { |route| route[:remapped_nap].to_s.casecmp(wanted) == 0 }
params[:routes] = first + (params[:routes] - first)
params
end
private
# nil unless the service answered 200 with a JSON object.
def parse_answer(http_query)
return nil unless http_query[:response_value].to_i == 200
answer = JSON.parse(http_query[:response_data].to_s)
answer.is_a?(Hash) ? answer : nil
rescue JSON::ParserError
nil
end
end
We recommend validating every configuration before you deploy it, since these are starting points to adapt to your network rather than production-ready code. That step matters more for routing than for most code, because a routing script runs in the call path: if it hits an error it doesn’t handle, ProSBC refuses the call as a temporary failure. We parsed every file with Prism, Ruby’s own parser, and ran each one against a test harness built on the documented BaseRouting interface. That catches typos and logic slips, but it can’t tell you how a configuration behaves with your traffic, which is what a run on ProSBC Lab or a trial instance is for.
Have a routing problem to solve?
Tell us what your routing has to do, and we can discuss how ProSBC can fit your needs. In the meantime, ProSBC Lab gives you a free, permanent three-session instance to build and test on. For the bigger picture, read our guide to API-driven call routing.
By submitting this form, your information will be processed in accordance with our Privacy Policy.
Prefer to get hands-on first? Build a free ProSBC lab and test it yourself.