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 ledgerAuthentication
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 commitmentA 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, failedReversal 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/:currencyA 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 onceCallbacks
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.exceptionErrors
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 reviewCertification
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 resultPassing 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 unavailableSandbox 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.