Skip to content

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_request handler 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.