Network Protocol

This is the language a connected payment network speaks to Ultraner. It describes financial intent, never a specific rail: nothing in a request names a mobile money operator, a bank or a gateway, because which rail carries a payment is a routing outcome rather than part of what the sender asked for.

Base URL: /network/v1. Once a participant has certified against v1, v1 does not change in a breaking way; breaking changes become v2.

Overview

Every payment follows the same path. You resolve who you are paying, get a price, commit to it, then execute. Each step is a separate call so that a rail needing to show its own confirmation screen can pause between them.

resolve recipient
      |
      v
route discovery        (which participants can reach them)
      |
      v
FX + fees              (priced against that route)
      |
      v
quote                  (single-use, time-boxed)
      |
      v
authorize              (funds reserved, quote consumed)
      |
      v
execute                (destination credits the recipient)
      |
      v
settlement obligation + double-entry ledger

Authentication

You sign every request. This is not a bearer token: a stolen header replayed later, or a body altered in transit, must not be usable. Ultraner stores only a hash of your secret, and it is shown to you exactly once when issued.

X-Participant-Id: uln_prt_...
X-Timestamp:      2026-09-10T12:00:00Z    (ISO-8601, within 5 minutes)
X-Nonce:          <unique per request>
X-Signature:      HMAC_SHA256(secret_hash, timestamp + "." + nonce + "." + raw_body)
Idempotency-Key:  <your key>              (required on state-changing calls)

Sign the raw body bytes, exactly as you send them. If you serialize your JSON, sign it, then re-serialize it before sending, the signature will not match: key order and whitespace are part of what is signed.

A nonce may be used once inside the five-minute window. A reused one is rejected as a replay. Rotating a credential does not break in-flight requests: signatures from any active credential are accepted during the cutover.

Request and response

// Success
{ "success": true, "data": { ... } }

// Failure
{ "success": false, "error": { "code": "ROUTE_NOT_FOUND", "message": "..." } }

Note this differs from Ultraner's merchant API, which returns a flat error shape. The network is a separate protocol with its own versioning, and participants integrate against this document.

Idempotency

Required on anything that changes state. If your connection drops mid-request, retry with the same key: you get the original response back rather than a second payment. Reusing a key with a different body is rejected outright, because that is a client bug rather than a retry.

same key + same body   -> the original response, replayed
same key + new body    -> 409 IDEMPOTENCY_KEY_CONFLICT
same key, still running -> 409 (the first request has not finished)

Directory

Resolves a public payment address to a routable destination. It returns what is needed to route and to let a payer confirm who they are paying, and nothing else: no name, no other addresses that identity holds, no history.

POST /network/v1/directory/resolve

{ "address": "+254712345678" }

{
  "success": true,
  "data": {
    "identity_id": "uln_usr_...",
    "address": "+254712345678",
    "country": "KE",
    "currency": "KES",
    "verified": true,
    "available_rails": [
      { "participant_id": "uln_prt_...", "display_name": "...",
        "capabilities": ["collect","payout"], "preferred": true }
    ]
  }
}

Phone addresses are normalized to E.164 on both write and read, so any format you send finds the same record.

Quotes

A quote is a price Ultraner commits to, for one route, for a short window. It is single-use. An expired quote is never silently repriced: you re-quote, because a payer must not be charged something they did not see.

POST /network/v1/quotes

{
  "sender":    { "type": "payment_address", "value": "+255741434313" },
  "recipient": { "type": "payment_address", "value": "+254712345678" },
  "amount":    { "value": 100000, "currency": "TZS" }
}

{
  "success": true,
  "data": {
    "quote_id": "uln_qte_...",
    "sender_amount": 100000, "sender_currency": "TZS",
    "recipient_amount": 500000, "recipient_currency": "KES",
    "applied_rate": "0.0500000000",
    "fee": 500,
    "total_sender_pays": 100500,
    "expires_at": "2026-09-10T12:05:00Z",
    "status": "active"
  }
}

Amounts are always integers in the currency's smallest unit. Currencies differ in precision: TZS has no minor unit, KES does. In the example above 100,000 TZS converts to 5,000 KES, expressed as 500,000 cents. Treating those as interchangeable is a factor-of-100 error.

Payments

POST /network/v1/payments             create the intent from a quote
GET  /network/v1/payments/:id         full state, history and settlement
POST /network/v1/payments/:id/authorize
POST /network/v1/payments/:id/execute
POST /network/v1/payments/:id/refresh  poll an async destination
POST /network/v1/payments/:id/reverse
POST /network/v1/payments/:id/refund

GET  /network/v1/routes/preview       what routing would do, no commitment

A payment cannot be authorized without a live quote, and cannot execute without being authorized. Pass auto_execute: true on create to run authorize and execute in one call.

Transaction lifecycle

Financial states, not just success and failure. Every transition is recorded, and illegal ones are rejected rather than quietly applied.

created -> validating -> authenticating -> routing
        -> authorized -> processing -> destination_accepted
        -> settled -> reconciled

Non-terminal exceptions: pending, disputed
Terminal: declined, timeout, rejected, reversed, refunded, expired, failed

Reversal versus refund

They are different operations and are not interchangeable. A reversal undoes an authorization before the money settled. Once the destination has accepted, the money is gone and the correct operation is a refund, which creates an opposite obligation rather than editing the original one. Attempting to reverse a settled payment returns INVALID_STATE_TRANSITION.

Timeouts

Every non-terminal state has a maximum dwell time. A transaction is never left open indefinitely, because an unanswered authorization is a liability rather than a pending success.

Settlement

When a payment reaches destination_accepted, exactly one settlement obligation is created in the same step, along with a balanced pair of ledger entries. Obligations accumulate into per-currency settlement windows, netted by default.

GET  /network/v1/settlements                    your windows
GET  /network/v1/settlements/:id               window plus its obligations
GET  /network/v1/settlements/:id/reconciliation
POST /network/v1/settlements/:id/close
GET  /network/v1/settlements/position/:currency

A window cannot be marked settled unless its net figure matches the ledger behind it. If they disagree the window is marked failed, not settled: settling on a number the books do not support is how a discrepancy becomes permanent.

Reconciliation

Ultraner compares its record against the settlement report you produce independently, under your own reference scheme. Differences are surfaced as exceptions and never auto-resolved, because quietly picking a side is how a real discrepancy disappears into a total that looks correct.

matched     amount and status agree
mismatched  the two sides disagree on amount or status
missing     one side has a record the other does not
duplicate   the same reference reported more than once

Callbacks

State changes are pushed to your endpoint, signed the same way your requests are, so you verify them with the code you already wrote to sign. Failed deliveries retry with backoff; a 4xx is treated as permanent.

payment.created            payment.destination_accepted
payment.authorized        payment.settled
payment.processing        payment.reversed
payment.failed            payment.refunded

settlement.created        settlement.completed
reconciliation.exception

Errors

PARTICIPANT_NOT_FOUND      unknown X-Participant-Id
SIGNATURE_INVALID          signature, timestamp or header problem
REPLAY_DETECTED            nonce reused inside the window
IDEMPOTENCY_KEY_CONFLICT   same key, different body
ADDRESS_NOT_RESOLVED       the directory has no such address
QUOTE_EXPIRED              re-quote, do not retry
QUOTE_ALREADY_CONSUMED     that quote already funded a payment
ROUTE_NOT_FOUND            nothing can carry this payment
PARTICIPANT_UNHEALTHY      the destination rail is failing health checks
CAPABILITY_NOT_SUPPORTED   the participant does not advertise that operation
INVALID_STATE_TRANSITION   e.g. reversing a settled payment
COMPLIANCE_HOLD            blocked pending review

Certification

The full suite is published so you can see exactly what you will be tested on and reproduce every case yourself first. It concentrates on failure paths, because those are the ones nobody exercises before going live and the ones that cost money when they are wrong.

GET  /network/v1/certification/suite        every test, in advance
POST /network/v1/certification/runs        run it against your connector
GET  /network/v1/certification/runs/latest your most recent result

Passing makes you eligible for production. It does not grant it: live access is a separate, deliberate decision covering compliance and a participant agreement. Certification against an older suite version does not carry forward, since that means untested on everything added since.

Sandbox

The sandbox is a pair of simulated networks that behave like real ones: they hold balances, refuse what they cannot fund, and issue their own references rather than echoing yours. Outcomes are driven deterministically by the last four digits of an address, so you can reproduce any scenario on demand.

...0001   success
...0002   insufficient funds
...0003   timeout
...0004   declined
...0005   unknown recipient
...0006   accepted for processing, resolves asynchronously
...0007   limit exceeded
...0008   temporarily unavailable

Sandbox and live participants are kept strictly apart: a sandbox payment can never route to a real operator, and a real payment can never land on a simulator.

Participant onboarding is handled by Ultraner directly. There is no self-signup on the protocol surface: nothing gets from “can send an HTTP request” to “is on the network” without a person deciding.