Online charging (Diameter Ro / OCS)¶
Prepaid charging talks to an Online Charging System over Diameter Ro (Credit-Control, RFC 8506 / 3GPP TS 32.299). For a voice call siphon reserves credit at setup, re-authorizes on the quota the OCS grants, and cuts the call when the OCS refuses further credit. For SMS/RCS it debits per message before delivery. This works against CGRateS out of the box.
Two models, both standards-defined:
| Service | Model | Flow |
|---|---|---|
| Voice | SCUR (session, reserve units) | CCR-INITIAL → CCR-UPDATE… → CCR-TERMINATION |
| SMS / RCS | IEC (one-shot debit) | CCR-EVENT (Requested-Action = DIRECT_DEBITING) |
The re-auth mechanism, the timer, and the mid-call disconnect are all Rust-side;
your script supplies the policy — whether to charge the call at all, which
subscriber, and what to answer on a denial. The gate is reserve-before-connect:
a @b2bua.on_invite handler calls await call.ro_authorize() before
call.dial(), so the B-leg is never dialed unless the OCS grants credit.
Ro is B2BUA-only. Enforcement — actually cutting the call when credit runs out — needs siphon to own and be able to tear down the session, which is a B2BUA capability. This matches 3GPP: online charging is triggered by the AS / MMTel-AS (TS 32.275), never by the P-CSCF (a P-CSCF is an offline/Rf node). So run the charging siphon as a B2BUA (e.g. an MMTel-AS on ISC). (The one-shot SMS/RCS IEC debit below has no session to tear down, so you can drive it from a
@proxy.on_requesthandler via the scripting API — but voice enforcement is B2BUA.)
Config¶
Point a Diameter route at your OCS and turn on ro:.
# siphon.yaml
diameter:
origin_host: "siphon.ims.example.org"
origin_realm: "ims.example.org"
peers:
- name: ocs1
host: "10.0.0.30"
port: 3868
destination_realm: "cgrates.org"
routes:
- application: ro # advertises Auth-Application-Id 4 in the CER
peers: ["ocs1"]
ro:
enabled: true
reauth_interval_secs: 30 # fallback cadence; the OCS-granted quota wins
requested_seconds: 30 # Requested-Service-Unit CC-Time (0 = empty RSU, OCS decides)
service_context_id: "32260@3gpp.org" # voice (32275 for MMTel-AS supplementary services)
sms_service_context_id: "32274@3gpp.org" # SMS / RCS
node_functionality: as # AS/MMTel-AS is the standard Ro trigger
charge: orig # orig | term | both
charge_from: answer # answer (default) | invite — where the chargeable
# clock starts. See "What gets billed" below.
on_ocs_failure: terminate # fail-closed; `continue` = fail-open (allow, uncharged)
credit_denied_status: 402 # SIP status a script returns when denied at setup
rating_group: 100 # optional; its presence selects the MSCC (multi-service) shape
peer: ocs1 # optional explicit OCS peer
Voice — the reserve-before-connect gate¶
Reserve credit in @b2bua.on_invite before dialing the B-leg. A grant
dials; a denial rejects and no B-leg is ever created. After a grant siphon runs
the whole SCUR lifecycle itself — CCR-UPDATE on the OCS-granted cadence, mid-call
disconnect on 4012 CREDIT_LIMIT_REACHED, CCR-TERMINATION on BYE — so the
handler is just the gate.
Every request in the session carries Service-Information describing the call
(calling and called party, ICID, User-Session-Id), not just the CCR-INITIAL,
so an OCS can attribute mid-call usage and the final record.
When the call was routed by LCR, Outgoing-Trunk-Group-Id names the carrier
that answered, or the last one dialled if none did. It is stamped on the
session at each attempt that reaches the wire and again at the answer, last
write wins, so it reaches every record after the first dial: a mid-call
re-authorization, and the CCR-TERMINATION of a call that was cancelled during
ringing, rang out, or was rejected by the carrier. Under sequential failover
that is not necessarily the carrier the CCR-INITIAL was built for, so it cannot
be inferred from the initial request — the CCR-INITIAL itself carries no carrier,
since it is sent by the ro_authorize() gate before any B-leg INVITE leaves.
The AVP is absent only when no carrier was ever dialled: no route was produced,
or every route was unroutable and no INVITE left the box. A carrier that was
considered but never reached is never named, which keeps the AVP consistent with
dialed on the CDR's lcr_attempts.
This matters for anything computed per carrier from the charging feed. An unanswered call used to name no carrier at all, so a carrier appeared only on the calls it answered and its answer-seizure ratio read 100 % by construction.
from siphon import b2bua, log
@b2bua.on_invite
async def route(call):
decision = await call.ro_authorize() # CCR-INITIAL, before any B-leg
if not decision["authorized"]:
# 4012 no balance, OCS unreachable (fail-closed), etc.
call.reject(402, "Payment Required")
return
log.info(f"reserved {decision['granted_time']}s, session {decision['session_id']}")
call.dial(str(call.ruri)) # credit reserved → connect
call.ro_authorize() returns {"authorized": bool, "result_code": int|None,
"granted_time": int|None, "session_id": str|None}. The charged party defaults to
the ro.charge config (orig = caller, term = callee); pass
subscription_id="+31…" / subscription_id="sip:alice@…" to override it (a
sip: URI is typed as a SIP URI, never mislabeled as an E.164 MSISDN). Rating
group, requested quota and Service-Context come from the ro: config block.
4011 CREDIT_CONTROL_NOT_APPLICABLE returns authorized: True with no session
(the call runs free of charge). Skip the ro_authorize() call entirely for calls
you don't want charged.
Manual CCR (advanced)¶
For full control — your own re-auth loop, non-standard subscriber handling — the
raw client is available in any mode and is async (await):
answer = await diameter.ro_ccr_initial(
call.from_uri, subscription_id_type="sip",
requested_seconds=30, rating_group=100,
calling_party=call.from_uri, called_party=call.to_uri,
sip_method="INVITE", role_of_node="originating", node_functionality="as",
)
sid = answer["session_id"]
# … later, on your own cadence …
await diameter.ro_ccr_update(call.from_uri, sid, 1, used_seconds=30, requested_seconds=30)
await diameter.ro_ccr_terminate(call.from_uri, sid, 2, used_seconds=12)
ro_ccr_initial returns {result_code, session_id, request_number,
granted_time, validity_time, final_unit_action} (or None when no OCS peer is
connected). Prefer call.ro_authorize() — it stores the session Rust-side and
runs the re-auth + teardown for you.
Script control (SMS / RCS — one-shot IEC)¶
Charge a page-mode MESSAGE before relaying it, and reject with 402 when the
balance is empty:
from siphon import proxy, diameter
@proxy.on_request("MESSAGE")
async def on_message(request):
answer = await diameter.ro_ccr_event(
request.from_uri,
subscription_id_type="sip",
service_context_id="32274@3gpp.org", # SMS charging (TS 32.274)
originator_address=request.from_uri,
recipient_address=request.to_uri,
sm_message_type=0, # submission
)
if answer and answer["result_code"] == 2001:
request.relay() # debited → deliver
else:
request.reply(402, "Payment Required") # no balance → reject
CGRateS DiameterAgent¶
CGRateS runs self-contained (data_db/stor_db = *internal). A minimal
diameter_agent request-processor for the voice CCR (grant the account's
balance as CC-Time, deny with 4012 when it hits zero):
{
"diameter_agent": {
"enabled": true, "listen": ":3868", "listen_net": "tcp",
"origin_host": "cgrates.org", "origin_realm": "cgrates.org",
"sessions_conns": ["*birpc_internal"],
"request_processors": [{
"id": "ro_initial",
"filters": ["*string:~*vars.*cmd:CCR", "*string:~*req.CC-Request-Type:1"],
"flags": ["*initiate", "*accounts"],
"request_fields": [
{"tag": "ToR", "path": "*cgreq.ToR", "type": "*constant", "value": "*voice"},
{"tag": "OriginID", "path": "*cgreq.OriginID", "type": "*variable", "value": "~*req.Session-Id"},
{"tag": "Account", "path": "*cgreq.Account", "type": "*variable",
"value": "~*req.Subscription-Id.Subscription-Id-Data"},
{"tag": "RequestType", "path": "*cgreq.RequestType", "type": "*constant", "value": "*prepaid"},
{"tag": "Usage", "path": "*cgreq.Usage", "type": "*variable",
"value": "~*req.Multiple-Services-Credit-Control.Requested-Service-Unit.CC-Time"}
],
"reply_fields": [
{"tag": "CCA", "type": "*template", "value": "*cca"},
{"tag": "GrantedUnits",
"path": "*rep.Multiple-Services-Credit-Control.Granted-Service-Unit.CC-Time",
"type": "*variable", "value": "~*cgrep.MaxUsage{*duration_seconds}"},
{"tag": "ResultCode", "filters": ["*eq:~*cgrep.MaxUsage:0"],
"path": "*rep.Result-Code", "type": "*constant", "value": "4012", "blocker": true}
]
}]
}
}
Seed a 30-second voice balance with one JSON-RPC call — no rating CSVs needed:
curl -s http://cgrates:2080/jsonrpc -d '{"method":"ApierV2.SetBalance",
"params":[{"Tenant":"cgrates.org","Account":"sip:alice@ims.example.org",
"BalanceType":"*voice","Value":30000000000}],"id":1}'
What gets billed¶
charge_from decides where the chargeable clock starts.
| Clock starts | Ring time billed? | |
|---|---|---|
answer (default) |
the 200 OK | no |
invite |
the CCR-INITIAL | yes |
answer is what TS 32.260 §5 means by chargeable duration: a call that rings
and is never answered has none, and reports 0 used seconds.
invite counts from the reservation — which happens before any carrier is
dialled — so ring time is billed. With two carriers at timeout_secs: 12, 24
seconds of a 30-second grant can be gone before the callee picks up, and it gets
worse the longer the carrier list. This was the only behaviour before
charge_from existed; it is kept for anyone who depended on it.
Only the clock moves. The reservation still happens at INVITE either way, because reserve-before-connect is the whole point of the prepaid gate.
When the call is answered siphon sends a CCR-UPDATE carrying Time-Stamps
(TS 32.299 §7.2.97) — SIP-Request-Timestamp is the INVITE that triggered the
reservation, SIP-Response-Timestamp is the answer — so the OCS can see when
charging began, and a Diameter-to-HTTP bridge has a connect event to translate.
It is idempotent: a retransmitted 200 OK neither restarts the clock nor sends a
second record.
Usage is reported as a delta against what the OCS has already acknowledged, so a CCR-UPDATE that fails leaves its seconds unreported and the next record — or the CCR-TERMINATION — still covers them exactly once.
Why a call ended¶
CCR-TERMINATION carries Cause-Code (TS 32.299 §7.2.35), from the same
disconnect cause Rf's ACR-STOP derives — the RFC 3326 Reason header, else the
SIP status — so the two interfaces never disagree about a call.
| Ending | Cause-Code |
|---|---|
| Normal hangup (BYE) | 0 |
| Busy | -486 |
| Ring timeout / no answer | -408 |
| Cancelled before answer | -487 |
| OCS refused further credit | -402 |
| Max-session-lifetime backstop | -408 |
Successful terminations map to 0 and failures pass through their negated SIP
status, which is what TS 32.299 reserves the negative range for. The two
siphon-initiated teardowns reuse the SIP status each corresponds to: 402
Payment Required is what a denied setup is already answered with, so an
out-of-credit teardown reports the same cause whenever the balance ran out.
Observability¶
siphon_ro_sessions gauges live credit-control sessions (CCR-INITIAL without a
matching CCR-TERMINATION); under a steady completed-call workload it returns to
~0. Alert on it climbing while call rate is flat — that's a charging-session
leak.
A session count tells you the OCS is answering. It cannot tell you what it is answering, and a refused call moves nothing else — the CCR/CCA round trip succeeded, and no SIP error counter fires. These three cover that:
| Metric | Labels | What it means |
|---|---|---|
siphon_diameter_answers_total |
command, result_code |
Every answer received, by Result-Code. siphon_diameter_request_errors_total counts only transport failures, so a peer that answers and refuses reads as zero errors there. |
siphon_ro_denials_total |
result_code |
A call refused credit at setup — a call that never happened. |
siphon_ro_credit_teardowns_total |
reason |
An established call cut off mid-way when credit ran out. |
Result-Code labels are bounded: codes siphon knows appear as themselves
(4012), anything else collapses into its RFC 6733 §7.1 class (4xxx_other),
and 3GPP Experimental-Result-Codes carry an exp: prefix so a vendor 5001 is
never conflated with a base 5001. The value comes off the wire from a peer, so
the label set is bounded at compile time rather than by what the peer sends.
Alerts worth having:
# The OCS is reachable and refusing. Invisible before these counters existed.
rate(siphon_ro_denials_total[5m]) > 0
# Credit ran out and nothing was wired to enforce it: the call is still up,
# and unpaid. `ro.teardown` must be connected for enforcement to happen.
increase(siphon_ro_credit_teardowns_total{reason="no_teardown_hook"}[15m]) > 0
# Answers that are not 2xxx, across every reference point.
sum(rate(siphon_diameter_answers_total{result_code!~"2.*"}[5m])) > 0
siphon_diameter_peer_up{peer} reports each configured peer as 0/1 by its name
from siphon.yaml. Every configured peer is published at 0 before its first
connect attempt, so a peer that has never come up reads as down rather than
being absent from the metric — alert on siphon_diameter_peer_up == 0, which
siphon_diameter_peers_connected (a bare count) cannot express per peer.