Ultraner

Ultraner

API Docs

Getting started

OverviewAuthentication

Real-life examples

Streaming / subscriptionsGame app / in-app purchasesSaaS platform billing

v1 · AzamPay (Tanzania)

MNO CheckoutBank CheckoutDisbursementCross-border (IMT)

v1 · AzamPay (Rwanda)

MNO · MTN & Airtel

v2 · Selcom (Tanzania)

MNO, Bank & Card

v3 · Pesapal (East Africa)

Hosted Checkout

v4 · MalipoPay (Tanzania)

MNO Collection & Payouts

Global Gateways

PayPalStripe

Universal APIs (/v0)

Universal CheckoutEmbed checkout on your siteExchange rates (Ultraner FX)Wallet & TransfersRecurring & SubscriptionsProduct CatalogFeatures & EntitlementsPricing TablesPromo CodesEscrowWebhooks
Gateway directory →
GatewaysOpen Console Get started
UltranerUltraner
Get started

Overview

Built for AI agents, LLMs, and vibe-coding tools too, not just humans.

View as Markdown Download .md

Ultraner is a global payment network connecting Africa to the world. One API covers mobile money across Tanzania and Rwanda today (with more African markets coming soon), card payments via Stripe, PayPal for international payers, and bank rails, all settling into a single Ultraner wallet in the recipient's local currency.

All requests go to https://api.ultraner.com with your API key in the X-API-Key header. Responses are always JSON.

Quick start

The simplest possible authenticated call, fetch your own wallet balance, in whichever language your backend runs:

View exampleHide example
curl https://api.ultraner.com/v1/wallet \
  -H "X-API-Key: uk_live_••••••••"
import { Ultraner } from "@ultraner/sdk";

const ultraner = new Ultraner(process.env.ULTRANER_KEY);

const wallet = await ultraner.wallet.retrieve();
console.log(wallet.balance, wallet.currency);
from ultraner import Ultraner

ultraner = Ultraner(api_key=os.environ["ULTRANER_KEY"])

wallet = ultraner.wallet.retrieve()
print(wallet.balance, wallet.currency)
$ultraner = new \Ultraner\Client(getenv('ULTRANER_KEY'));

$wallet = $ultraner->wallet->retrieve();
echo $wallet->balance . ' ' . $wallet->currency;
require "ultraner"

Ultraner.api_key = ENV["ULTRANER_KEY"]

wallet = Ultraner::Wallet.retrieve
puts "#{wallet.balance} #{wallet.currency}"
client := ultraner.NewClient(os.Getenv("ULTRANER_KEY"))

wallet, err := client.Wallet.Retrieve(ctx)
fmt.Println(wallet.Balance, wallet.Currency)
Ultraner ultraner = new Ultraner(System.getenv("ULTRANER_KEY"));

Wallet wallet = ultraner.wallet().retrieve();
System.out.println(wallet.getBalance() + " " + wallet.getCurrency());
var ultraner = new UltranerClient(Environment.GetEnvironmentVariable("ULTRANER_KEY"));

var wallet = await ultraner.Wallet.RetrieveAsync();
Console.WriteLine($"{wallet.Balance} {wallet.Currency}");
let ultraner = Ultraner::new(env::var("ULTRANER_KEY")?);

let wallet = ultraner.wallet().retrieve().await?;
println!("{} {}", wallet.balance, wallet.currency);

How currency conversion works

Ultraner moves money across four different currency situations, worth understanding before you integrate:

Mobile money, Adaptive Pricing

Same-country payments need no conversion: a Tanzanian phone pays into a TZS wallet, a Rwandan phone pays into an RWF wallet. Across the two, it's live today too, a Rwandan phone can pay a TZS-priced link and is billed the live-converted RWF amount, while the recipient is still credited their exact original TZS. Same principle as card/PayPal below; more corridors light up as new mobile money gateways go live.

Card & PayPal, Adaptive Pricing

A payer anywhere in the world pays in their own currency (or USD, PayPal's only settlement currency), the African recipient is always credited the exact local amount their link or charge was for, see how it works.

Cross-border (IMT)

A sender outside Tanzania, in USD, KES, UGX, GBP, EUR and more, transfers directly into a TZS wallet at a live rate, see #v1-tz-imt below.

Wallet-to-wallet transfers

Sending between two Ultraner wallets is instant and free, 0 fees, even across currencies. The sender pays in their own currency, the recipient gets the converted amount in theirs, at Ultraner's plain mid rate with no margin, since this is the one rail Ultraner never earns FX spread on.

Every rate Ultraner uses is public and live at ultraner.com/fx, grouped by region with buy and sell prices for every market Ultraner operates in. And every converted transaction gets its own page showing exactly what the payer paid, the rate applied, the gateway fee, and the net credited, at ultraner.com/fx/{transactionId}, the same transactionId you already have from the charge response or a payment.success webhook.

Amounts are always in the currency's smallest unit

Every amount you send to Ultraner (payment links, /stripe/sessions, /paypal/orders, everywhere) follows the same rule Stripe itself uses: an integer in the smallest unit of that currency. For 2-decimal currencies (USD, EUR, GBP) that's cents, so 100 means $1.00, not $100.00. For zero-decimal currencies (TZS, RWF, UGX, KES, and the rest of Stripe's zero-decimal list , JPY included) there are no subunits, so 100 just means 100 of that currency. This applies symmetrically: reading a transaction back (its amount, fee, and fee_breakdownfields) follows the exact same rule. The one deliberate exception is any field whose own comment says "major units" (like charged_amount in the Stripe/PayPal responses below), those are already human-readable and should not be divided again.

Start here: what are you building?

Pick the closest real-life example, each one walks through an actual product integrating all three payment methods end to end.

A streaming or subscription app (like Netflix or Spotify)A subscriber picks a plan and a payment method, gets billed every period, access unlocks on webhook.A game app selling in-app currencyA player buys gems or coins instantly, across mobile money, card, or PayPal.A SaaS platform with paid plansA customer upgrades from Free to Pro; your backend charges them and provisions the plan.Accept mobile money directly (M-Pesa, MTN MoMo, Airtel Money)Send a USSD push to the customer's phone, they approve on their handset.Build a marketplace with escrowLock a buyer's payment until a condition is met, then release it to the seller.Get notified the moment a payment settlesSubscribe to signed webhooks instead of polling for status.

API routes

/v0UniversalGlobal, checkout, escrow, recurring, webhooksLive
/v1AzamPayTanzania · RwandaLive
/v2SelcomTanzaniaComing Soon
/v4MalipoPayTanzaniaBeta
/paypalPayPalGlobal (USD → local currency)Live
/stripeStripeGlobal, Visa, Mastercard, AmexLive
/v3PesapalKenya, Uganda, Tanzania, Rwanda…Beta

Country routing

The API version selects the gateway. Within a version, Ultraner auto-routes to the correct country configuration based on the phone number prefix in your request.

View exampleHide example
# Tanzania (prefix 255), routed to AzamPay TZ config
POST https://api.ultraner.com/v1/payments/express/mno
{ "account_number": "255712345678", "provider": "Airtel", "amount": 50000 }

# Rwanda (prefix 250), same endpoint, routed to AzamPay RW config
{ "account_number": "250781234567", "provider": "MTN", "amount": 20000 }

# International payer → African recipient via PayPal
POST https://api.ultraner.com/paypal/orders
{ "token": "pl_live_xxxx", "currency": "USD" }

# International payer → African recipient via Stripe card
POST https://api.ultraner.com/stripe/sessions
{ "token": "pl_live_xxxx" }

Authentication

Pass your API key on every request. Keys are prefixed by environment:

uk_live_ProductionCharges real money
uk_test_SandboxSimulated transactions
View exampleHide example
curl https://api.ultraner.com/v1/wallet \
  -H "X-API-Key: uk_live_your_key_here" \
  -H "Content-Type: application/json"

Live keys require KYC + an authorized domain

A uk_live_ key can only be created once your account (and any sub-business you operate) has completed KYC verification , this is a regulatory requirement from your country's central bank, not just an Ultraner policy. Each live key is also tied to one approved authorized domain: on every live request, we check the calling Origin or Referer header against that domain, and reject the request (403 DOMAIN_NOT_AUTHORIZED) if it doesn't match. A pure server-to-server call that sends neither header is unaffected, this only stops a live key from being used on a website it wasn't issued for.

You authorize domains during KYC and any time after: add the domain you'll call Ultraner from and submit it for review from your business verification (the Domains & Webhooks step). Once Ultraner approves it, you can generate a live API key bound to it and start processing live payments from that site. Need another site later? Add it, send it for review, and generate a key for it the same way, an unapproved domain simply can't process live payments.

None of this applies to uk_test_ keys, sandbox mode has no KYC or domain requirement, so you can build and test from any URL, including localhost, before you're verified.

Sandbox mode

Every rail has a sandbox counterpart. Add -sandbox to the version segment of the URL (/v1-sandbox/..., /v2-sandbox/..., /v0-sandbox/...) and use a uk_test_ key. A payment link created with a test key is a test link, and paying it produces a mode: test transaction that never credits a real wallet balance and never counts toward revenue. Going live later is just deleting -sandbox from the URL and swapping the key.

Mobile money and bank sandbox is fully simulated (no USSD, no operator call). Card and PayPal sandbox is different: it runs against Stripe's and PayPal's own real sandboxes, so you test with genuine test cards and sandbox buyer accounts and see a realistic checkout, fee, and receipt, all still isolated as mode: test on our side.

The URL and the key must agree, a live key on a -sandbox URL is rejected with 401, and a test key on a live URL is rejected the same way. This is deliberate: it's what stops a sandbox response from ever being paired with a real webhook.

View exampleHide example
# Sandbox
POST https://api.ultraner.com/v1-sandbox/payments/express/mno
X-API-Key: uk_test_...

# Live, same body, same response shape, only the URL and key changed
POST https://api.ultraner.com/v1/payments/express/mno
X-API-Key: uk_live_...

Testing card & PayPal payments

Card and PayPal test payments use the providers' standard sandbox test data:

Stripe test cards

Ultraner

4242 4242 4242 4242

Succeeds

Ultraner

4000 0000 0000 0002

Generic decline

Ultraner

4000 0000 0000 9995

Insufficient funds

Any future expiry date, any 3-digit CVC, and any ZIP. Full list at stripe.com/testing.

PayPal sandbox cards

Ultraner
American Express

3405 746585 43130

Exp 08/2031 · CVC any 3 digits

Ultraner
Visa

4032 0305 7755 0040

Exp 07/2031 · CVC any 3 digits

Ultraner
Mastercard

5110 9205 7115 9602

Exp 08/2031 · CVC any 3 digits

Ultraner
Discover

6440 5708 0477 3650

Exp 08/2031 · CVC any 3 digits

Use any of these when the PayPal flow reaches its card-entry option. To test the full PayPal login approval flow instead, create your own sandbox buyer account in your PayPal developer dashboard (Testing Tools, Sandbox Accounts), sandbox account credentials are tied to your own developer account and shouldn't be shared or reused across projects.

Before you go live

Every sandbox payment is tagged mode: testand is automatically excluded from your real wallet balance, transaction totals, and revenue, so test activity never inflates your live numbers. But your own app should still treat sandbox as throwaway: don't persist sandbox transaction / order IDs as if they were real orders, and switch your integration to your live uk_live_ key (and drop -sandbox from the URL) before taking real payments. Live and test are fully independent, going live changes nothing about the test data you already created.

Error format

View exampleHide example
// All errors return the same shape
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "account_number is required"
  }
}

Example: A streaming or subscription app

Bingehub sells a Basic and a Premium monthly plan. At signup, a subscriber picks a plan and pays with whichever method they have, mobile money, card, or PayPal, and Bingehub needs to bill them again every month without lifting a finger.

1. Subscriber picks Premium and a payment method

Bingehub's own checkout screen shows three buttons: M-Pesa/Mobile Money, Card, PayPal. Whichever the subscriber taps, the backend creates one recurring plan with a matching channel, see Recurring & Subscriptions for the full reference:

View exampleHide example
POST https://api.ultraner.com/v1/recurring/plans
X-API-Key: uk_live_...

// Subscriber tapped "M-Pesa"
{ "name": "Premium plan", "amount": 15000, "currency": "TZS", "interval_type": "monthly",
  "mno_provider": "Vodacom", "account_number": "255712345678" }

// Subscriber tapped "Card"
{ "name": "Premium plan", "channel": "card", "amount": 15000, "currency": "TZS", "interval_type": "monthly",
  "return_url": "https://bingehub.app/billing/return", "cancel_url": "https://bingehub.app/billing/cancel",
  "payer_email": "subscriber@example.com" }

// Subscriber tapped "PayPal", same shape, channel: "paypal"

2. Approve once, then Stripe/PayPal bill automatically

Mobile money is already active, Ultraner sends a fresh USSD push every month, the subscriber approves each one. Card and PayPal come back with a redirect_url, the subscriber approves once, and from then on Stripe or PayPal auto-charges them every month, Bingehub never has to prompt them again.

3. Webhooks unlock and keep unlocking Premium

View exampleHide example
{ "event": "recurring.activated", "data": { "plan_id": "UTRA-Rpl-...", "channel": "card" } }
// First payment approved, unlock Premium now.

{ "event": "recurring.success", "data": { "plan_id": "UTRA-Rpl-...", "amount": 15000, "currency": "TZS" } }
// Fires every month, on every channel, keep Premium unlocked.

{ "event": "recurring.past_due", "data": { "plan_id": "UTRA-Rpl-..." } }
// A card/PayPal renewal failed but is still retrying, show a billing warning, don't cut access yet.

{ "event": "recurring.cancelled", "data": { "plan_id": "UTRA-Rpl-..." } }
// Payer cancelled from their own manage_url, or the gateway gave up, downgrade to Free.
Every plan's create response includes a manage_url, send that to the subscriber (or email it, Ultraner will if you pass payer_email) and they can view or cancel their own subscription anytime, no Bingehub account needed, no support ticket. It's the same self-service experience Stripe's customer portal gives payers, available for all three payment methods.

Example: A game app selling in-app currency

Vuma Legends sells gem top-ups (e.g. 500 gems for TZS 3,000) as one-off purchases. Players expect the gems to land almost immediately, and the game must never grant them before the payment is actually confirmed.

1. Player taps "Buy 500 gems"

View exampleHide example
POST https://api.ultraner.com/v0/payments/charge
X-API-Key: uk_live_...

// Same three methods as any other purchase
{ "method": "mobile_money", "amount": 3000, "currency": "TZS", "phone": "255712345678", "provider": "Airtel" }
{ "method": "card",   "amount": 3000, "currency": "TZS", "description": "500 gems",
  "return_url": "https://vumalegends.app/purchase/return", "cancel_url": "https://vumalegends.app/purchase/cancel" }
{ "method": "paypal", "amount": 3000, "currency": "TZS", "description": "500 gems",
  "return_url": "https://vumalegends.app/purchase/return", "cancel_url": "https://vumalegends.app/purchase/cancel" }

// Response, save transactionId against the pending purchase
{ "success": true, "data": { "transactionId": "txn_...", "method": "mobile_money", "gateway": "azampay", "status": "pending" } }

2. Never grant the reward optimistically

Show a "confirming…" state and wait for either the payment.success webhook or a status poll, whichever arrives first, before crediting gems. A USSD approval or Stripe/PayPal redirect can fail or be abandoned, granting the reward before that is confirmed is exactly how digital goods get charged back.

View exampleHide example
// Fallback if you'd rather poll than wait on the webhook alone
GET https://api.ultraner.com/v0/payments/{transactionId}
X-API-Key: uk_live_...

{ "success": true, "data": { "id": "txn_...", "status": "success", "amount": 3000, "currency": "TZS" } }
// status is "pending" | "success" | "failed"

3. Credit the gems, idempotently

Whichever signal lands first, webhook or poll, credit the gems keyed off transaction_id and mark that purchase as fulfilled so a webhook-then-poll race (or a retried webhook) can never grant the same gems twice.

Example: A SaaS platform with paid plans

Taskflois a project-management tool with Free, Pro, and Business plans. A customer on Free clicks "Upgrade to Pro" inside the app.

1. Upgrade click → create a recurring plan

Pro is billed monthly, so Taskflo creates a recurring plan rather than a one-off charge, see Recurring & Subscriptions. B2B customers usually pay by card, but nothing stops Taskflo from offering mobile money or PayPal the same way Bingehub does:

View exampleHide example
POST https://api.ultraner.com/v1/recurring/plans
X-API-Key: uk_live_...

{
  "name": "Taskflo Pro",
  "channel": "card",
  "amount": 49,
  "currency": "USD",
  "interval_type": "monthly",
  "return_url": "https://taskflo.io/billing/return",
  "cancel_url": "https://taskflo.io/billing/cancel",
  "payer_email": "owner@customer.com"
}

// Response
{
  "success": true,
  "data": {
    "id": "UTRA-Rpl-...",
    "channel": "card",
    "status": "pending_approval",
    "redirect_url": "https://checkout.stripe.com/pay/cs_...",
    "manage_url": "https://ultraner.com/manage/mng_..."
  }
}

2. Redirect once, then provision on every webhook

Taskflo sends the browser to redirect_url and stores the plan id against the workspace. The plan only actually upgrades when a webhook confirms it, the return page alone is not proof of payment, and Stripe re-bills automatically every month after that, no further redirects needed:

View exampleHide example
{ "event": "recurring.activated", "data": { "plan_id": "UTRA-Rpl-..." } }
// First payment approved, upgrade the workspace to Pro now.

{ "event": "recurring.success", "data": { "plan_id": "UTRA-Rpl-...", "amount": 49, "currency": "USD" } }
// Fires every month, keep Pro active, email the receipt.

{ "event": "recurring.cancelled", "data": { "plan_id": "UTRA-Rpl-..." } }
// Customer cancelled from their manage_url, or a failing renewal was finally given up on, downgrade to Free.
This is the same shape as the streaming example, one endpoint, one channel field, five webhook events. Whether it's a $49 SaaS seat or a TZS 15,000 consumer subscription, the integration is identical, and every customer gets their own manage_url to cancel without ever emailing support.
/v1Tanzania · AzamPayLive

MNO Checkout

Sends a USSD push to the customer's mobile money wallet. The customer approves the prompt on their phone. Supported operators: Vodacom, Airtel, Tigo, Halopesa, Azampesa, Mpesa.

Initiate payment

View exampleHide example
POST https://api.ultraner.com/v1/payments/express/mno

{
  "account_number": "255712345678",
  "amount": 50000,
  "provider": "Airtel",
  "currency": "TZS"
}

// Response
{
  "success": true,
  "message": "USSD push sent. Approve the prompt on your phone.",
  "data": {
    "transaction_id": "txn_...",
    "external_id": "EXT_...",
    "status": "pending"
  }
}

Check status

View exampleHide example
GET https://api.ultraner.com/v1/payments/express/status/{reference}

// Response
{
  "success": true,
  "data": {
    "transactionId": "txn_...",
    "message": "Payment confirmed",
    "success": true
  }
}

Sandbox Test in sandbox

Same endpoint, prefixed: POST https://api.ultraner.com/v1-sandbox/payments/express/mno with a uk_test_ key. No USSD push is sent, no AzamPay call is made. The outcome is decided by the account_number you send:

Ends in 700000001Instant success
Ends in 700000002Insufficient funds (fails)
Ends in 700000003Times out, never resolves, stays pending
amount 1-100 (smallest unit)Simulated provider outage (5xx)
Any other numberSucceeds after a random 3-8s delay

Successful and failed outcomes still run through the exact same callback-handling code a real AzamPay callback would, ledger update, webhook, real-time event, just triggered by the fixture instead of AzamPay, after a short simulated delay.

Callback (inbound)

AzamPay posts to your configured callback URL when the payment settles. The body is signed with RSA-SHA256.

View exampleHide example
POST https://your-app.com/callback

{
  "message": "Transaction completed successfully",
  "user": "merchant_user",
  "password": "merchant_password",
  "clientId": "client_123",
  "transactionstatus": "success",
  "operator": "Airtel",
  "reference": "REF123456",
  "externalreference": "EXT987654",
  "utilityref": "UTIL001",
  "amount": "50000",
  "transid": "TXN123456",
  "msisdn": "255712345678",
  "mnoreference": "MNO_REF_001",
  "submerchantAcc": null,
  "signature": "Base64EncodedSignature..."
}

// Verify signature: SHA256(utilityref + externalreference + transactionstatus + operator)
// Public key: GET https://api.ultraner.com/v1/providers/azampay/public-key
/v1Tanzania · AzamPayLive

Bank Checkout

Collect from CRDB or NMB bank accounts using a one-time OTP the customer generates via USSD.

Step 1, Customer generates OTP

View exampleHide example
# CRDB: Dial *150*03# → 7 Other Services → 5 AzamPay → Link Account
# NMB:  Dial *150*66# → 8 More → 5 Register Sarafu → 1 Select Account No.

Step 2, Initiate bank checkout

View exampleHide example
POST https://api.ultraner.com/v1/payments/express/bank

{
  "merchant_account_number": "0123456789",
  "merchant_mobile_number": "255712345678",
  "otp": "123456",
  "amount": 100000,
  "provider": "CRDB",
  "currency_code": "TZS"
}

// Response
{
  "success": true,
  "message": "Bank payment submitted.",
  "data": { "transaction_id": "txn_...", "status": "pending" }
}

Sandbox Test in sandbox

POST https://api.ultraner.com/v1-sandbox/payments/express/bank with a uk_test_ key. The OTP step (/v1-sandbox/payments/express/bank/otp) accepts the request but never sends a real SMS, and any OTP value is accepted at checkout, it isn't validated in sandbox. The outcome is keyed off merchant_mobile_number, same scheme as MNO checkout:

Ends in 700000001Instant success
Ends in 700000002Insufficient funds (fails)
Ends in 700000003Times out, never resolves, stays pending
amount 1-100 (smallest unit)Simulated provider outage (5xx)
Any other numberSucceeds after a random 3-8s delay
/v1Tanzania · AzamPayLive

Disbursement

Send funds from your Ultraner wallet to any mobile money account in Tanzania. Supported operators: Airtel, Azampesa, Tigo.

Disbursement fee. Every payout out of Ultraner carries a flat 3% Ultraner feeon top of whatever the underlying gateway charges (for example MalipoPay's TZS 500 per mobile-money payout). It's debited from your wallet alongside the payout and refunded in full if the payout fails. The transaction detail and webhook data break out the gross, gateway fee, Ultraner fee, and net.

Name lookup (verify before sending)

View exampleHide example
POST https://api.ultraner.com/v1/disbursements/lookup

{
  "provider": "Airtel",
  "account_number": "255712345678"
}

// Response
{
  "success": true,
  "data": {
    "name": "John Doe",
    "accountNumber": "255712345678",
    "bankName": "Airtel"
  }
}

Send disbursement

View exampleHide example
POST https://api.ultraner.com/v1/disbursements

{
  "recipient_provider": "Airtel",
  "recipient_account": "255712345678",
  "recipient_name": "John Doe",
  "amount": 25000,
  "currency": "TZS",
  "remarks": "Payroll March 2026"
}

// Response
{
  "success": true,
  "data": {
    "id": "dsb_...",
    "pg_reference_id": "b42aeas4hl3d...",
    "status": "pending"
  }
}

// AzamPay posts a callback when the transfer settles
// Body: { initiatorReferenceId, fspReferenceId, pgReferenceId, amount, status, operator }
// Signature: SHA512({partnerSecret}{initiatorReferenceId}{pgReferenceId})
/v1Tanzania · AzamPayLive

Cross-border (IMT)

International money transfers from other countries into Tanzania via AzamPay's IMT API. Supports USD, KES, UGX, GBP, EUR and more. FX rates are fetched live.

Get FX rates

View exampleHide example
GET https://api.ultraner.com/v1/crossborder/rates

{
  "success": true,
  "data": {
    "USD": 0.000385,
    "KES": 0.05,
    "UGX": 1.43,
    "GBP": 0.000305
  }
}

Name lookup

View exampleHide example
POST https://api.ultraner.com/v1/crossborder/lookup

{ "bank_name": "Azampesa", "account_number": "255712345678" }

Send transfer

View exampleHide example
POST https://api.ultraner.com/v1/crossborder

{
  "recipient_provider": "Azampesa",
  "recipient_account": "255712345678",
  "recipient_name": "Jane Smith",
  "destination_country": "TZ",
  "destination_currency": "TZS",
  "amount": 50000,
  "remarks": "Family support"
}

// IMT transferDetails sent to AzamPay:
// { type: "IMT", amount, dateInEpoch, transactionOriginCountry, initialTransactionDatetime }
// Callback signature: RSA-SHA512({partnerSecret}{initiatorReferenceId}{pgReferenceId})
/v1Rwanda · AzamPayLive

Rwanda, MNO Checkout

Same /v1/payments/express/mno endpoint as Tanzania. Ultraner detects Rwanda automatically from the 250 phone prefix and routes to the Rwanda-configured AzamPay environment.

Supported operators: MTN, Airtel.

Initiate payment

View exampleHide example
POST https://api.ultraner.com/v1/payments/express/mno

{
  "account_number": "250781234567",
  "amount": 20000,
  "provider": "MTN",
  "currency": "RWF"
}

// Same response shape as Tanzania
{
  "success": true,
  "message": "USSD push sent. Approve the prompt on your phone.",
  "data": {
    "transaction_id": "txn_...",
    "status": "pending"
  }
}

Routing logic

View exampleHide example
// Ultraner resolves the country internally:
// 250XXXXXXXXX → Rwanda → AzamPay RW config
// 255XXXXXXXXX → Tanzania → AzamPay TZ config
//
// Your integration code never changes.
// You pass the full international MSISDN and the right provider for that country.

// Rwanda providers
"provider": "MTN"    // MTN Rwanda
"provider": "Airtel" // Airtel Rwanda

Rwanda credentials

Rwanda uses separate AzamPay credentials from Tanzania. Set AZAMPAY_CLIENT_ID_RW and AZAMPAY_CLIENT_SECRET_RW in your environment. Falls back to Tanzania credentials if not set.

Sandbox Test in sandbox

POST https://api.ultraner.com/v1-sandbox/payments/express/mno, the fixture scheme matches on the number itself, not the country prefix, so a Rwanda MSISDN ending in the same digits (e.g. 250781700000001) triggers the same outcomes as Tanzania:

Ends in 700000001Instant success
Ends in 700000002Insufficient funds (fails)
Ends in 700000003Times out, never resolves, stays pending
amount 1-100 (smallest unit)Simulated provider outage (5xx)
Any other numberSucceeds after a random 3-8s delay
/v2Tanzania · SelcomLive

Selcom, MNO, Bank & Card

Selcom provides mobile money, bank transfer, disbursement, bill payment, and card collection for Tanzania. Supported MNO operators: Vodacom, Airtel, Tigo, Halotel.

The Selcom rail is coming soon and not yet enabled for live traffic. The endpoints below are documented ahead of launch; sandbox is available for building against.

MNO collection

View exampleHide example
POST https://api.ultraner.com/v2/payments/express/mno

{
  "phone": "255712345678",
  "amount": 50000,
  "provider": "Vodacom",
  "reference": "order_9182"
}

// Selcom initiates USSD push. Poll status:
GET https://api.ultraner.com/v2/payments/express/status/{reference}

Sandbox Test in sandbox

POST https://api.ultraner.com/v2-sandbox/payments/express/mno with a uk_test_ key. Selcom has no separate async failure push in real life (a payment either gets confirmed or the customer never completes it), so the fixture scheme matches that shape, insufficient funds resolves as an immediate failed status rather than a delayed callback:

Ends in 700000001Instant success
Ends in 700000002Insufficient funds (fails)
Ends in 700000003Times out, never resolves, stays pending
amount 1-100 (smallest unit)Simulated provider outage (5xx)
Any other numberSucceeds after a random 3-8s delay

Disbursement

View exampleHide example
POST https://api.ultraner.com/v2/disbursements

{
  "recipient_provider": "Vodacom",
  "recipient_account": "255712345678",
  "amount": 25000,
  "currency": "TZS"
}

Bill payment

View exampleHide example
// Lookup the bill first
POST https://api.ultraner.com/v2/bills/lookup
{ "utilitycode": "LUKU", "utilityref": "123456789" }

// Pay the bill
POST https://api.ultraner.com/v2/bills/pay
{
  "utilitycode": "LUKU",
  "utilityref": "123456789",
  "amount": 10000,
  "currency": "TZS"
}
/v3East Africa · PesapalLive

Pesapal, Hosted Checkout

Pesapal covers Kenya, Uganda, Tanzania, Rwanda, Burundi, Malawi, Zambia and Zimbabwe with a single hosted checkout: the customer is redirected to a payment page where they pick M-Pesa, Airtel Money, card or bank, then returns to your callback URL. Currently in beta. There is no USSD push from your side, and no disbursement API, payouts stay on the v1/v2/v4 rails.

Create a checkout session

View exampleHide example
POST https://api.ultraner.com/v3/payments/checkout

{
  "amount": 50000,
  "currency": "KES",          // KES, TZS, UGX, USD...
  "description": "Order #001",
  "callback_url": "https://yoursite.com/payment/complete",
  "email": "payer@acme.com",  // optional
  "phone": "254712345678",    // optional
  "country": "KE"             // optional, ISO alpha-2
}

// 201 Created
{
  "success": true,
  "data": {
    "transaction_id": "txn_...",
    "reference": "...",
    "redirect_url": "https://pay.pesapal.com/..."   // send the customer here
  }
}

// Check status any time (also pushed automatically via IPN):
GET https://api.ultraner.com/v3/payments/status/{reference}

Sandbox Test in sandbox

POST https://api.ultraner.com/v3-sandbox/payments/checkout with a uk_test_key. Like card and PayPal, Pesapal sandbox runs against Pesapal's own real test environment, you get a genuine hosted page with their test M-Pesa and card credentials, still isolated as mode: test on our side and never crediting a real wallet.

/v4Tanzania · MalipoPayLive

MalipoPay, MNO Collection & Payouts

MalipoPay (by Lockwood Technology) covers all five Tanzanian MNOs: M-Pesa, Mixx by Yas, Airtel Money, Halotel and TTCL Pesa, plus CRDB/NMB bank collections and card processing. Currently in beta. As with every rail, you only ever talk to Ultraner endpoints, the gateway's own API is never exposed.

/v3 is reserved for Pesapal (Kenya, Uganda, Tanzania, Rwanda, Burundi, Malawi, Zambia, Zimbabwe), the next gateway on the roadmap, which is why this one is /v4.

MNO collection

View exampleHide example
POST https://api.ultraner.com/v4/payments/express/mno

{
  "msisdn": "255712345678",
  "amount": 50000,
  "currency": "TZS",
  "channel": "mpesa"     // mpesa | mixx | airtel | halotel | ttcl
}

// 202 Accepted, MalipoPay pushes USSD to the customer's phone
{ "success": true, "data": { "transaction_id": "txn_...", "status": "pending" } }

// Poll status (fallback, webhooks are pushed automatically):
GET https://api.ultraner.com/v4/payments/express/status/{reference}

Sandbox Test in sandbox

POST https://api.ultraner.com/v4-sandbox/payments/express/mno with a uk_test_ key. The standard fixture scheme applies, outcomes settle through the same webhook path a real MalipoPay callback would, just triggered by the fixture:

Ends in 700000001Instant success
Ends in 700000002Insufficient funds (fails)
Ends in 700000003Times out, never resolves, stays pending
amount 1-100 (smallest unit)Simulated provider outage (5xx)
Any other numberSucceeds after a random 3-8s delay

Hosted checkout link

Creates a MalipoPay-hosted payment page and returns the URL to redirect your customer to. Live mode only while the rail is in beta.

View exampleHide example
POST https://api.ultraner.com/v4/payments/checkout-link

{
  "amount": 50000,
  "currency": "TZS",
  "description": "Order #001",
  "callback_url": "https://yoursite.com/payment/complete"
}

// 201 Created
{ "success": true, "data": { "transaction_id": "txn_...", "checkout_url": "https://..." } }

Disbursement

Payouts are fully asynchronous: the request is accepted immediately, your wallet is debited, and if the operator later fails the payout the funds are automatically returned before the webhook fires. Funds are never lost.

View exampleHide example
POST https://api.ultraner.com/v4/disbursements

{
  "recipient_msisdn": "255712345678",
  "amount": 25000,
  "currency": "TZS",
  "channel": "airtel",
  "remarks": "Refund order 9182"
}
/paypalGlobal · USD → local currencyLive

PayPal

Ultraner holds a US PayPal merchant account and always settles PayPal in USD, unlike Stripe, PayPal has no way to present a card/PayPal-balance charge in the payer's own currency. You can still point any payment link at it regardless of the link's currency: Ultraner converts local to USD at a live rate and bills the payer that USD figure, then credits the recipient's wallet the exact local amount the link is for, the payer's bank/PayPal balance absorbs the USD conversion, not the recipient. Recipients never need a PayPal account. See live rates for every market at /fx, and exactly how a specific payment converted at /fx/{transactionId} (each transaction gets its own page).

Create order

View exampleHide example
POST https://api.ultraner.com/paypal/orders
// Public, no auth required

{
  "token": "pl_live_xxxx",   // payment link token
  "amount": 5000,            // only for variable-amount links
  "currency": "USD",         // optional; see below
  "email": "payer@acme.com"  // optional, we email them the receipt
}

// PayPal always settles in USD. By default "amount" is in the LINK'S currency
// (e.g. TZS) and we convert to USD at our rate. If you pass "currency": "USD",
// "amount" is treated as USD cents (like every amount field in this API) and
// charged directly with no conversion, 100 means $1.00, not $100.00. Either
// way PayPal's minimum is 1.00 USD.

// Response
{
  "success": true,
  "data": {
    "transaction_id": "txn_...",
    "paypal_order_id": "5O190127TN364715T",
    "approve_url": "https://www.paypal.com/checkoutnow?token=...",
    "charged_in_local": false,   // true = payer billed in the link currency
    "charged_currency": "USD",   // what the payer is actually charged in
    "charged_amount": 1.94       // major units, for showing/confirming
  }
}

// Redirect the payer to approve_url.
// PayPal redirects back to your return URL after approval.

Capture order

View exampleHide example
POST https://api.ultraner.com/paypal/orders/{orderId}/capture
// Public, call this after the payer approves

// Response
{
  "success": true,
  "message": "Payment captured and credited."
}

// Settlement is idempotent, so the redirect capture and Ultraner's own
// reconciliation can both run safely without double-crediting.

Getting notified

You never talk to PayPal directly. Ultraner captures the payment, credits the recipient wallet in local currency, and notifies your server through an Ultraner webhook, see payment.success under Webhooks in the sidebar.

Payouts (optional)

View exampleHide example
POST https://api.ultraner.com/paypal/payouts
// Authenticated

{
  "receiver_email": "payer@example.com",
  "amount_usd": 50.00,
  "note": "Freelance payment"
}

Sandbox Testing PayPal in sandbox

These payment-link endpoints don't have a sandbox counterpart yet. For sandbox PayPal testing today, use the authenticated universal charge endpoint instead, see method: "paypal" under Universal Checkout (/v0) in the sidebar.

/stripeGlobal · Visa, Mastercard, AmexLive

Stripe

Ultraner holds a US Stripe merchant account. Anyone with a card (Visa, Mastercard, Amex) can pay via Stripe-hosted checkout. This is Ultraner's Adaptive Pricing, see how it works: for most currencies Stripe presents and charges the card directly in the payer's own currency (charged_in_local: truebelow), no conversion at all. Where Stripe can't present a currency, Ultraner falls back to charging in USD at a live rate. Either way the African recipient's wallet is credited the exact local amount the link is for, see live rates for every market at /fx, and the exact conversion applied to any transaction at /fx/{transactionId}.

Create checkout session

View exampleHide example
POST https://api.ultraner.com/stripe/sessions
// Public, no auth required

{
  "token": "pl_live_xxxx",   // payment link token
  "amount": 50000,           // in the LINK'S currency; only for variable-amount links
  "email": "payer@acme.com"  // optional, we email them the receipt
}

// "amount" is in the link's own currency (e.g. TZS), not USD, and, like every
// amount field in this API, is an integer in that currency's smallest unit.
// TZS has no subunit, so 50000 above means 50,000 TZS exactly. If the link
// were USD, the same field would be cents: 5000 would mean $50.00, not $5,000.
// Stripe's minimum is ~0.50 USD, so the amount must be worth at least that
// once converted.

// Response
{
  "success": true,
  "data": {
    "transaction_id": "txn_...",
    "stripe_session_id": "cs_test_...",
    "checkout_url": "https://checkout.stripe.com/pay/cs_test_...",
    "charged_in_local": true,    // Stripe presents most currencies directly
    "charged_currency": "TZS",   // what the payer is actually charged in
    "charged_amount": 1500       // major units, for showing/confirming
  }
}

// Redirect the payer to checkout_url.
// Stripe redirects back to your return URL after payment.

Confirm session

View exampleHide example
POST https://api.ultraner.com/stripe/sessions/{sessionId}/confirm
// Public, call this after the payer returns from Stripe

// Response
{
  "success": true,
  "message": "Payment confirmed and credited.",
  "data": {
    "amount": 1500, "currency": "TZS", "method": "card",
    "reference": "UTRA-TxN-...", "payee": "Acme Ltd"
  }
}

// Settlement is idempotent, so the redirect confirm and Ultraner's own
// reconciliation can both run safely without double-crediting.

Getting notified

You never talk to Stripe directly. Ultraner settles the charge, credits the recipient's wallet in their local currency, and notifies your server through an Ultraner webhook, see payment.success under Webhooks in the sidebar.

Sandbox Testing Stripe in sandbox

These payment-link endpoints don't have a sandbox counterpart yet. For sandbox card testing today, use the authenticated universal charge endpoint instead, see method: "card" under Universal Checkout (/v0) in the sidebar.

/v0Universal, gateway-agnostic public APIs

Universal Checkout (/v0)

/v0 is the universal public API surface, not tied to any specific gateway or country. It handles checkout, escrow, and webhooks in a way that works across all Ultraner gateways. Third-party integrations use /v0.

Two ways to accept a payment. Payment links, create one and share the URL, the payer opens Ultraner's own hosted checkout page (ultraner.com/pay/{token}) and picks mobile money, card, or PayPal themselves, zero code beyond creating the link. Universal charge, your backend picks the method and calls /v0/payments/chargedirectly, the response redirects straight to Stripe's or PayPal's own checkout page (or sends a USSD push for mobile money), best for billing flows embedded in your own product.

Resolve a payment link or QR code

View exampleHide example
GET https://api.ultraner.com/v0/pay/resolve/{token}

// Payment link (pl_*), public anonymous checkout
{
  "success": true,
  "data": {
    "kind": "payment_link",
    "requires_auth": false,
    "title": "Coffee",
    "business_name": "Acme Shop",
    "amount": 5000,
    "currency": "TZS",
    "fixed_amount": true,
    "expires_at": null
  }
}

// QR code, wallet-to-wallet (requires Ultraner account)
{
  "success": true,
  "data": {
    "kind": "qr",
    "requires_auth": true,
    "title": "Acme Shop",
    ...
  }
}

Universal checkout (mobile money)

View exampleHide example
POST https://api.ultraner.com/v0/pay/checkout
// Public, for payment links (pl_*) only. QR requires auth.

{
  "token": "pl_live_xxxx",
  "phone": "255712345678",
  "provider": "Airtel",
  "payerCountry": "TZ",
  "amount": 5000        // only for variable-amount links
}

// Ultraner detects the country from the phone prefix and routes to
// the correct gateway (AzamPay TZ, AzamPay RW, etc.) automatically.
{
  "success": true,
  "message": "USSD push sent.",
  "data": { "transaction_id": "txn_...", "status": "pending" }
}

For card and PayPal payments via a payment link, use the /stripe/sessions and /paypal/orders endpoints directly, they accept the same token parameter.

Create a checkout session token (no dashboard)

Mint a checkout token programmatically instead of creating a payment link by hand in the console, the Ultraner equivalent of stripe.checkout.sessions.create. It returns a one-time, expiring cs_token plus a ready hosted URL; the payer opens it (or you embed it) and picks mobile money, card, or PayPal themselves, just like Stripe Checkout's hosted page.

Sessions are deliberately distinct from payment links: a pl_ link is one you create and manage in the console and can share repeatedly, while a cs_ session is minted per order, expires on its own, and is kept out of your Payment Links list (see the Checkout sessions tab). Both are paid through the same hosted checkout.

View exampleHide example
POST https://api.ultraner.com/v0/checkout/sessions
X-API-Key: uk_live_...

{ "amount": 50000, "currency": "TZS", "title": "Order #42",
  "expires_in_minutes": 1440 }   // 24h default, max 30 days

// 201
{
  "success": true,
  "data": {
    "token": "cs_live_xxxx",
    "url": "https://ultraner.com/pay/cs_live_xxxx",
    "embed_url": "https://ultraner.com/pay/cs_live_xxxx?embed=1",
    "amount": 50000, "currency": "TZS", "mode": "live",
    "expires_at": "2026-07-22T10:00:00.000Z", "status": "open"
  }
}

Add is_recurring: true with a recurring_interval to mint a subscription session. Retrieve one anytime with GET /v0/pay/resolve/{token}. Sandbox: POST https://api.ultraner.com/v0-sandbox/checkout/sessions with a uk_test_key. With the SDK it's one call:

View exampleHide example
import { Ultraner } from "@ultraner/sdk";
const ultraner = new Ultraner(process.env.ULTRANER_API_KEY);

const session = await ultraner.checkout.sessions.create({
  amount: 50000, currency: "TZS", title: "Order #42",
});
// redirect the payer to session.url, or embed session.token inline

Check a session's status directly

Webhooks are the source of truth for a completed payment, but a delivery can be delayed or missed (a brief outage on your endpoint, a network blip). Poll this endpoint as a safety net for anything your webhook handler hasn't already confirmed, for example a session still pending a few minutes after it was created.

View exampleHide example
GET https://api.ultraner.com/v0/checkout/sessions/{token}/status
X-API-Key: uk_live_...

// 200 - a payer has attempted this session
{
  "success": true,
  "data": {
    "status": "success",   // "pending" | "success" | "failed" | "reversed"
    "transaction_id": "txn_...",
    "updated_at": "2026-07-22T10:04:12.000Z"
  }
}

// 200 - nobody has attempted payment yet (not an error)
{ "success": true, "data": { "status": "no_transaction" } }

Only the business that created the session can read its status. If more than one payment attempt was made on the same session (e.g. a first mobile money push failed and the payer retried), this returns the most recent attempt.

Pay by username, phone, or email

Anywhere a QR or payment-link token works, /pay/{identifier}also accepts a human-friendly identifier that resolves straight to a person's or business's wallet. The same resolve and checkout calls above work unchanged, they just return kind: "identifier".

View exampleHide example
GET https://api.ultraner.com/v0/pay/resolve/@grack      // username (with or without @)
GET https://api.ultraner.com/v0/pay/resolve/grack       // same wallet
GET https://api.ultraner.com/v0/pay/resolve/255712345678   // phone number
GET https://api.ultraner.com/v0/pay/resolve/name@example.com   // email

{ "success": true, "data": { "kind": "identifier", "business_name": "Grack", "currency": "TZS", ... } }

Usernames are claimed in Settings and must be similar to the account's real name (a "Jane Doe" account can't take @elonmusk); users and businesses share one username namespace, so /pay/{name}is never ambiguous. A username is always public once set. A phone or email only resolves as a pay target if the account holder explicitly enables "discoverable by phone/email" in settings (both off by default), so neither can be probed to learn whether it has an Ultraner account.

Sandbox Test in sandbox

POST https://api.ultraner.com/v0-sandbox/pay/checkout with the same body. No real gateway is called regardless of which country/network the phone number resolves to. Outcome is keyed off phone, same scheme as the gateway-specific endpoints:

Ends in 700000001Instant success
Ends in 700000002Insufficient funds (fails)
Ends in 700000003Times out, never resolves, stays pending
amount 1-100 (smallest unit)Simulated provider outage (5xx)
Any other numberSucceeds after a random 3-8s delay

Universal charge (authenticated)

For businesses that already have a wallet and an API key rather than a payment link, /v0/payments/charge is the single endpoint for all three payment methods. Ultraner picks the gateway internally.

View exampleHide example
POST https://api.ultraner.com/v0/payments/charge
// Authenticated (X-API-Key or Bearer)

// mobile money
{ "method": "mobile_money", "amount": 50000, "phone": "255712345678", "provider": "Airtel" }

// card (Stripe)
{ "method": "card", "amount": 50000, "currency": "TZS", "description": "Order #42",
  "return_url": "https://your-app.com/success", "cancel_url": "https://your-app.com/cancel" }

// paypal
{ "method": "paypal", "amount": 50000, "currency": "TZS", "description": "Order #42",
  "return_url": "https://your-app.com/success", "cancel_url": "https://your-app.com/cancel" }

// card/paypal responses include a redirectUrl, send the payer there.
// Note the response fields are camelCase even though the request body above
// is snake_case, that's the real shape of this endpoint, not a typo.
{
  "success": true,
  "data": {
    "transactionId": "txn_...",
    "method": "card",
    "gateway": "stripe",
    "status": "pending",
    "redirectUrl": "https://checkout.stripe.com/pay/cs_..."
  }
}

Or call it from an SDK, in whichever language your backend runs:

View exampleHide example
curl https://api.ultraner.com/v0/payments/charge \
  -H "X-API-Key: uk_live_••••••••" \
  -H "Content-Type: application/json" \
  -d '{
    "method": "mobile_money",
    "amount": 50000,
    "phone": "255712345678",
    "provider": "Airtel"
  }'
import { Ultraner } from "@ultraner/sdk";

const ultraner = new Ultraner(process.env.ULTRANER_KEY);

const charge = await ultraner.payments.charge({
  method: "mobile_money",
  amount: 50000,
  phone: "255712345678",
  provider: "Airtel",
});
from ultraner import Ultraner

ultraner = Ultraner(api_key=os.environ["ULTRANER_KEY"])

charge = ultraner.payments.charge(
    method="mobile_money",
    amount=50000,
    phone="255712345678",
    provider="Airtel",
)
$ultraner = new \Ultraner\Client(getenv('ULTRANER_KEY'));

$charge = $ultraner->payments->charge([
  'method'   => 'mobile_money',
  'amount'   => 50000,
  'phone'    => '255712345678',
  'provider' => 'Airtel',
]);
require "ultraner"

Ultraner.api_key = ENV["ULTRANER_KEY"]

charge = Ultraner::Payment.charge(
  method: "mobile_money",
  amount: 50000,
  phone: "255712345678",
  provider: "Airtel"
)
client := ultraner.NewClient(os.Getenv("ULTRANER_KEY"))

charge, err := client.Payments.Charge(ctx, &ultraner.ChargeParams{
    Method:   "mobile_money",
    Amount:   50000,
    Phone:    "255712345678",
    Provider: "Airtel",
})
Ultraner ultraner = new Ultraner(System.getenv("ULTRANER_KEY"));

Charge charge = ultraner.payments().charge(
    ChargeParams.builder()
        .method("mobile_money")
        .amount(50000)
        .phone("255712345678")
        .provider("Airtel")
        .build()
);
var ultraner = new UltranerClient(Environment.GetEnvironmentVariable("ULTRANER_KEY"));

var charge = await ultraner.Payments.ChargeAsync(new ChargeOptions
{
    Method = "mobile_money",
    Amount = 50000,
    Phone = "255712345678",
    Provider = "Airtel",
});
let ultraner = Ultraner::new(env::var("ULTRANER_KEY")?);

let charge = ultraner.payments().charge(ChargeParams {
    method: "mobile_money".into(),
    amount: 50_000,
    phone: "255712345678".into(),
    provider: "Airtel".into(),
    ..Default::default()
}).await?;

Sandbox Test in sandbox, all three methods

POST https://api.ultraner.com/v0-sandbox/payments/charge with a uk_test_key. This is the one endpoint where every payment method's sandbox behavior is reachable:

method: "mobile_money", same MSISDN fixture scheme as above (Selcom via prefer_gateway: "selcom" is TZ-only, same fixtures apply).

Ends in 700000001Instant success
Ends in 700000002Insufficient funds (fails)
Ends in 700000003Times out, never resolves, stays pending
amount 1-100 (smallest unit)Simulated provider outage (5xx)
Any other numberSucceeds after a random 3-8s delay

method: "card" and method: "paypal"don't use a magic value, real card/PayPal checkout never gives Ultraner a card number or PayPal login either, the customer enters it on the gateway's own hosted page. So the redirectUrl in sandbox points to an interactive confirmation page Ultraner itself hosts instead:

View exampleHide example
redirectUrl: "https://api.ultraner.com/sandbox-simulate/stripe/{sessionId}"   // method: card
redirectUrl: "https://api.ultraner.com/sandbox-simulate/paypal/{orderId}"     // method: paypal

// That page shows the amount and two buttons: Confirm / Cancel.
// No card number or PayPal login is collected, it's simulating the
// outcome, not the payment form. Confirming settles the transaction
// through the exact same code a real Stripe/PayPal webhook would use.

Embed checkout on your site

Drop a payment link straight into your own page or app, no redirect to a separate Ultraner-hosted page. Every method, mobile money, card, and PayPal, completes inline inside the embed. Card uses Stripe's own Embedded Checkout; PayPal uses PayPal's own JS SDK Buttons; both replace the classic redirect-to-gateway flow entirely.

Recurring links embed too. A link created with is_recurringworks the same way inline, card mounts Stripe's embedded subscription checkout, PayPal renders its inline Subscribe button, and mobile money signs the payer up in place, so a subscription can be sold from inside your own page without ever leaving it.

1. Include the SDK

View exampleHide example
<script src="https://ultraner.com/embed.js"></script>

2. Inline, embedded directly in the page

View exampleHide example
<div id="ultraner-checkout"></div>
<script src="https://ultraner.com/embed.js"></script>
<script>
  Ultraner.embed({
    token: "pl_live_xxxx",
    container: "#ultraner-checkout",
    onSuccess: function (data) { console.log("Paid!", data); },
    onError:   function (err)  { console.error(err); },
  });
</script>

3. Button that opens a modal

View exampleHide example
<button id="pay-button">Pay now</button>
<script src="https://ultraner.com/embed.js"></script>
<script>
  Ultraner.embed({
    token: "pl_live_xxxx",
    trigger: "#pay-button",
    mode: "modal",
    onSuccess: function (data) { console.log("Paid!", data); },
    onClose:   function ()     { console.log("Closed without paying"); },
  });
</script>

Every payment link has ready-to-paste versions of both snippets, plus a live preview, under Payment Links → Get embed code in the business portal.

postMessage events

The embed communicates via window.postMessage. If you're not using embed.js (e.g. building your own wrapper around a raw <iframe src="https://ultraner.com/pay/{token}?embed=1">), listen for these directly, and always check event.origin equals https://ultraner.com before trusting a message, that check is the actual security boundary of the whole embed.

ultraner:readyThe embed finished loading.
ultraner:resizeContent height changed, payload: { height }. Used to auto-size the iframe.
ultraner:successPayment completed, payload: { data: { amount, currency, method, reference, ... } }.
ultraner:errorPayment failed, payload: { message }.
ultraner:cancelThe payer closed a PayPal/Stripe overlay without completing payment.

Still a small overlay, and why

The payer never leaves your page, there is no full-page redirect to ultraner.com. But card and PayPal each still run their own brief, provider-controlled authentication step, 3-D Secure for a card, logging into a PayPal account, inside a small overlay that Stripe or PayPal itself controls. That's the same category of step as any embedded card integration on the web, not a limitation of this embed, what's eliminated is the full-page redirect away from your site.

Exchange rates (Ultraner FX)

No auth required, public and free to call. Use these to know a rate before you charge anyone, instead of guessing or hard-coding a number that goes stale. Every rate here is the exact one Ultraner itself uses at settlement, live at ultraner.com/fx.

Ultraner quotes a mid rate (the raw market rate, no margin) and a buy/sellrate (mid plus Ultraner's FX spread). Incoming payments, i.e. money a payer sends that gets converted into your currency, always settle at the sell rate. If you price something in your own currency and want to know what a payer in another currency will actually be charged, use /fx/quote below, it already applies sell for you. Use /fx/convert only when you explicitly want the plain mid rate, e.g. for display purposes. Plain-language walkthrough: Ultraner FX, know your rate before you charge anyone.

List every current rate

View exampleHide example
GET https://api.ultraner.com/v0/fx/rates

{
  "success": true,
  "data": {
    "rates": [
      { "base": "USD", "quote": "TZS", "rate": 2600.0, "buy": 2567.5, "sell": 2632.5, "spread_bps": 250, "manual": false, "source": "market", "fetched_at": "2026-07-11T09:00:00Z" }
    ],
    "currencies": ["TZS", "USD", "KES", "UGX", "EUR", "GBP", "RWF"]
  }
}

Convert at the mid rate

Good for showing an estimate. Not the number to charge a payer, see /fx/quote for that.

View exampleHide example
GET https://api.ultraner.com/v0/fx/convert?from=TZS&to=USD&amount=10000

{
  "success": true,
  "data": { "from": "TZS", "to": "USD", "amount": 10000, "converted": 385, "rate": 0.000385 }
}

Quote what a payer will actually be charged

You price something in your own currency, this tells you exactly what a payer in a different currency will pay, using the same sell rate Ultraner applies when that payment settles. Both amounts are integers in each currency's smallest unit, cents for USD/EUR/GBP, whole units for TZS/RWF/UGX/KES, same convention as everywhere else in the API.

View exampleHide example
GET https://api.ultraner.com/v0/fx/quote?sellerAmount=10000&sellerCurrency=TZS&payerCurrency=USD

{
  "success": true,
  "data": {
    "sellerAmount": 10000,
    "sellerCurrency": "TZS",
    "payerAmount": 380,
    "payerCurrency": "USD",
    "rate": 2632.5,
    "midRate": 2600.0,
    "side": "sell"
  }
}
View exampleHide example
const res = await fetch(
  "https://api.ultraner.com/v0/fx/quote?sellerAmount=10000&sellerCurrency=TZS&payerCurrency=USD"
);
const { data } = await res.json();
console.log(`Charge the payer ${data.payerAmount / 100} ${data.payerCurrency}`);
import requests

r = requests.get(
    "https://api.ultraner.com/v0/fx/quote",
    params={"sellerAmount": 10000, "sellerCurrency": "TZS", "payerCurrency": "USD"},
)
data = r.json()["data"]
print(f"Charge the payer {data['payerAmount'] / 100} {data['payerCurrency']}")
curl "https://api.ultraner.com/v0/fx/quote?sellerAmount=10000&sellerCurrency=TZS&payerCurrency=USD"

No quote_id to redeem, this is a live spot quote, not a locked-in rate, the market can move by the time the actual payment happens. If you need the rate held for longer than the moment you call this, ask support about locking a rate for a specific payment link.

Wallet & Transfers

Universal APIs, work regardless of which country or gateway the user is in.

Get balance

View exampleHide example
GET https://api.ultraner.com/v1/wallet

{
  "success": true,
  "data": {
    "id": "wlt_...",
    "balance": 482000,
    "currency": "TZS",
    "wallet_number": "255712345678"
  }
}

Internal transfer (wallet → wallet)

View exampleHide example
POST https://api.ultraner.com/v1/transfer

{
  "to_wallet_number": "255787654321",
  "amount": 10000,
  "note": "Invoice #42"
}

// Settles instantly. Both parties receive a real-time WebSocket event.

Transaction history

View exampleHide example
GET https://api.ultraner.com/v1/transactions?page=1&limit=20

{
  "success": true,
  "data": [
    {
      "id": "txn_...",
      "type": "transfer",
      "direction": "debit",
      "amount": 10000,
      "currency": "TZS",
      "status": "success",
      "created_at": "2026-04-25T10:00:00Z"
    }
  ]
}

Recurring & Subscriptions

One authenticated endpoint creates a recurring plan across any of the three payment methods, set channel to mobile_money (default), card, or paypal. Card and PayPal are real gateway subscriptions, Stripe and PayPal own the billing schedule and auto-charge the payer every interval, exactly like Stripe Billing. Mobile money has no such stored-credential concept on any network, so Ultraner's own engine sends a fresh USSD push every interval instead, the payer still approves each one on their phone.

Every plan, on every channel, also gets a public manage_url, a no-account, unguessable link the payer can open anytime to view or cancel their own subscription. You never have to build that yourself.

Recurring hosted payment links.You don't need this API at all to sell a subscription, create a payment link with is_recurring: true and a recurring_interval (daily/weekly/monthly/custom_days, fixed amount) and every payer who checks out on the /pay/{token} page gets their own subscription on whichever method they pick, hosted or embedded inline on your own site (see Embed checkout). Works in the sandbox with a uk_test_ link too.

Create a plan

View exampleHide example
POST https://api.ultraner.com/v1/recurring/plans
X-API-Key: uk_live_...

// Mobile money, channel omitted, same shape as before card/PayPal existed
{
  "name": "Premium plan",
  "mno_provider": "Vodacom",
  "account_number": "255712345678",
  "amount": 15000,
  "currency": "TZS",
  "interval_type": "monthly"
}

// Card, Stripe owns the schedule from here
{
  "name": "Premium plan",
  "channel": "card",
  "amount": 15000,
  "currency": "TZS",
  "interval_type": "monthly",
  "return_url": "https://bingehub.app/billing/return",
  "cancel_url": "https://bingehub.app/billing/cancel",
  "payer_email": "subscriber@example.com"
}

// PayPal, same shape, channel: "paypal"

// Response (card/paypal include redirect_url, send the payer there once to approve)
{
  "success": true,
  "data": {
    "id": "UTRA-Rpl-...",
    "channel": "card",
    "status": "pending_approval",
    "redirect_url": "https://checkout.stripe.com/pay/cs_...",
    "manage_url": "https://ultraner.com/manage/mng_..."
  }
}

Confirm after the payer approves

Card and PayPal both redirect the payer back to your own return_url, your return page calls the matching public confirm endpoint (idempotent, safe alongside the webhook below):

View exampleHide example
POST https://api.ultraner.com/v0/subscriptions/confirm/stripe/{sessionId}
POST https://api.ultraner.com/v0/subscriptions/confirm/paypal/{subscriptionId}
// Public, no API key, mirrors /stripe/sessions/{id}/confirm for one-off payments

Manage, pause, cancel

View exampleHide example
GET    https://api.ultraner.com/v1/recurring/plans              // list your plans
GET    https://api.ultraner.com/v1/recurring/plans/{id}
POST   https://api.ultraner.com/v1/recurring/plans/{id}/pause    // mobile_money only pauses cleanly between cycles
POST   https://api.ultraner.com/v1/recurring/plans/{id}/resume
DELETE https://api.ultraner.com/v1/recurring/plans/{id}          // cancels immediately by default
DELETE https://api.ultraner.com/v1/recurring/plans/{id}?immediate=false  // cancel at period end instead
GET    https://api.ultraner.com/v1/recurring/plans/{id}/charges  // history of every charge attempt

The payer doesn't need any of the above, their own manage_urlfrom the create response lets them view the plan and cancel it directly, no Ultraner account required, the same way a Stripe customer manages a subscription from Stripe's own portal. Plain-language walkthrough, with where to put the link and what customers actually see: Let customers manage their own subscription.

manage_urlis only ever handed back once, in the create response - if you didn't store it against the subscription in your own backend, or need to relink a returning customer to it later, GET /v1/recurring/plans/{id} includes the same field, so it's always fetchable again with just the plan id.

Cancel at period end, and resume

Cancelling from the manage page (or the API with immediate: false / ?immediate=false) doesn't end access right away - the plan stays active and usable until its current period actually ends, and is resumable until then, undoing the cancellation with nothing charged. Mobile money and card both support this fully: mobile money via Ultraner's own scheduling, card via Stripe's native cancel_at_period_end.

PayPal is the one exception. Its API has no cancel-at-period-end concept, only immediate cancel, or suspend/reactivate (a different thing, it stops billing and access right away). A PayPal plan is always cancelled immediately regardless of what's requested - the manage page's confirmation message reflects that honestly rather than promising a grace period PayPal can't actually give.

The authenticated DELETEabove defaults the other way from the manage page on purpose: a programmatic caller almost always means "stop now", so it cancels immediately unless you explicitly opt into the gentler behavior.

Receipts

Every successful recurring charge, on any channel, auto-issues a receipt, emailed and/or texted (SMS) to whichever contact the payer has on file, with a download/resend link shown against that charge in the manage page's billing history.

Webhooks

recurring.activatedrecurring.successrecurring.past_duerecurring.failedrecurring.cancelled

recurring.activatedfires once - for card/paypal, when the payer approves; for mobile money (no separate approval step), on the plan's first successful charge instead. recurring.success fires every time a charge is collected, on any channel including that same first one, credit your own records the same way regardless of which channel it was. recurring.past_due means a card/PayPal renewal failed but the gateway is still retrying, the plan is not cancelled yet. recurring.cancelled fires whether the payer cancelled from their manage link, you cancelled via the API, or the gateway gave up retrying.

Mobile money is the one channel Ultraner schedules itself, card and PayPal bill off-session on the gateway's own clock, Ultraner just reacts to their webhooks. Either way your integration is identical: read channel, react to the same five webhook events, and never touch a card number or PayPal login.

Product Catalog

Stripe-style Products & Prices: define what you sell once, then create payment links or checkout sessions straight from a price instead of typing an amount every time. A product can carry multiple prices from day one, e.g. Monthly vs Yearly on the same plan.

Create a product and its prices

View exampleHide example
POST https://api.ultraner.com/v1/business/products
X-API-Key: uk_live_...

{ "name": "Consulting Retainer", "description": "Monthly consulting hours" }

// 201 -> { "success": true, "data": { "id": "UTRA-Prd-...", "name": "...", "prices": [] } }

POST https://api.ultraner.com/v1/business/products/{productId}/prices
{
  "nickname": "Monthly",
  "amount": 50000,
  "currency": "TZS",
  "is_recurring": true,
  "recurring_interval": "monthly"
}

// omit "amount" for an open price the payer chooses at checkout

GET /v1/business/products lists every product with its prices embedded, GET /v1/business/products/{id} fetches one, PATCH edits name/description. DELETE archives, it never hard-deletes. Same for a price: PATCH /v1/business/products/{id}/prices/{priceId} and DELETE on the same path archive it. An archived product or price stops appearing as a choice for new links, anything already referencing it keeps working unaffected.

Sell from a price

Pass price_id instead of amount/currencywhen creating a payment link or a checkout session, the price's own values become authoritative:

View exampleHide example
POST https://api.ultraner.com/v1/business/payment-links
{ "title": "Consulting Retainer", "price_id": "UTRA-Prc-..." }

POST https://api.ultraner.com/v0/checkout/sessions
{ "price_id": "UTRA-Prc-..." }
This is a snapshot, not a live reference. The price's values are copied onto the link/session at the instant it's created and never re-read afterward, editing or archiving the price later never changes a link already handed to a customer, exactly like Stripe.

With the SDK

View exampleHide example
const product = await ultraner.products.create({ name: 'Consulting Retainer' });
const price = await ultraner.prices.create(product.id, { amount: 50000, currency: 'TZS' });
const session = await ultraner.checkout.sessions.create({ priceId: price.id });

Lookup keys, reference a price without hardcoding its id

Give a price a lookup_key, a stable, developer-chosen alias unique per business. Anywhere price_id is accepted, price_lookup_key works instead:

View exampleHide example
POST https://api.ultraner.com/v1/business/products/{productId}/prices
{ "nickname": "Monthly", "amount": 50000, "currency": "TZS", "lookup_key": "pro-monthly" }

POST https://api.ultraner.com/v0/checkout/sessions
{ "price_lookup_key": "pro-monthly" }

Your code can say "charge for pro-monthly" without ever storing or hardcoding the generated UTRA-Prc-... id.

Carts, sell more than one price at once

Pass line_items instead of amount/price_idto sell several prices together in one checkout, like Stripe Checkout's own cart:

View exampleHide example
POST https://api.ultraner.com/v0/checkout/sessions
{
  "line_items": [
    { "price_id": "UTRA-Prc-aaa", "quantity": 2 },
    { "price_id": "UTRA-Prc-bbb", "quantity": 1 }
  ]
}
The total is computed once, at creation, from each price's own amount × quantity (all items must share one currency), and stored on the resulting link/session exactly like a single-price snapshot, editing a price afterward never changes an already-created cart. A cart supports 1-20 items; every item's price must be active, have a fixed amount (no open-amount prices in a cart), and be one-time, a recurring price can't be mixed into a cart, use price_id on its own for a recurring link. POST /v1/business/payment-links accepts the same line_items field.

Gross-up pricing, the payer covers the fee (and VAT) so you get your full amount

By default, a new price's amount is what you actually receive, not what the payer is charged. Set transaction_fees_included: false to switch back to the old behavior (the payer is charged your amountexactly, and the gateway's collection fee is deducted from your proceeds at settlement, as before). This mirrors how most everyday transactions already work across Africa: the receiver gets the full amount, fees are the payer's problem.

View exampleHide example
POST https://api.ultraner.com/v1/business/products/{productId}/prices
{
  "nickname": "Monthly", "amount": 50000, "currency": "TZS",
  "transaction_fees_included": true,
  "vat_tax_percentage": 18
}

vat_tax_percentage is optional and independent of transaction_fees_included- goods often already include VAT, services often don't, so set it per price when it applies. VAT is computed on your amount alone (never on the fee itself) and always added on top when set, regardless of the fee toggle.

Worked example: amount 50000 TZS, MalipoPay's rate is 2.5%, VAT is 18%.

View exampleHide example
vat            = 50000 * 0.18                      = 9000
target         = 50000 + 9000                       = 59000   (your price + VAT)
charge_amount  = round(target / (1 - 0.025))         = 60513   (what the payer pays)
transaction_fee_amount = charge_amount - 50000 - 9000 = 1513

You are credited 59000 TZS (your 50000 price plus the 9000 VAT you collected, for you to remit to your tax authority separately), the payer paid 60513 TZS, and MalipoPay's fee (1513) was covered entirely by the markup, never deducted from your 50000.

View exampleHide example
GET https://api.ultraner.com/v0/pay/fee-quote/{token}?method=mno&payerCountry=TZ&provider=Mpesa&amount=50000

// 200 -> {
//   "seller_net_target": 50000, "transaction_fee_amount": 1513,
//   "vat_tax_percentage": 18, "vat_amount": 9000, "currency": "TZS",
//   "charge_amount": 60513, "fees_included": true
// }
method is mno, card, or paypal. For mno, payerCountry and providerare required - the actual gateway depends on the payer's own country/network (a Tanzanian M-Pesa payer resolves to MalipoPay; other countries resolve to AzamPay), so the quote resolves the same gateway the real charge will use rather than assuming a fixed one. A successful payment.success webhook for a fees-included charge carries the same breakdown under data.transparency.

Set your default once, in the dashboard

transaction_fees_included/vat_tax_percentagecan be set per price (as above), or once as your account-wide default under Business Settings → Checkout → Fees & tax - any new price that doesn't explicitly override these falls back to your saved default.

View exampleHide example
GET https://api.ultraner.com/v1/business/checkout-fee-defaults
// -> { "transaction_fees_included": true, "vat_tax_percentage": 18 }

PATCH https://api.ultraner.com/v1/business/checkout-fee-defaults
{ "transaction_fees_included": true, "vat_tax_percentage": 18 }

Features & Entitlements

Attach named capabilities to a product (premium_support, api_access), then check whether a customer's active subscription grants them access, without building your own subscription-to-permission mapping.

Create a feature and attach it to a product

View exampleHide example
POST https://api.ultraner.com/v1/business/features
{ "name": "Premium support", "key": "premium_support" }

POST https://api.ultraner.com/v1/business/products/{productId}/features/{featureId}   // attach, no body
DELETE https://api.ultraner.com/v1/business/products/{productId}/features/{featureId} // detach

Create a subscription from a price

Pass price_id instead of typing amount/interval_type by hand, so Ultraner knows which product the plan is for:

View exampleHide example
POST https://api.ultraner.com/v1/recurring/plans
{ "channel": "card", "price_id": "UTRA-Prc-...", "return_url": "...", "cancel_url": "..." }

Check entitlement

By the same identifier the subscriber paid with, their phone (account_number) or payer_email:

View exampleHide example
GET https://api.ultraner.com/v1/business/entitlements/check?customer=255712345678&feature_key=premium_support

-> { "success": true, "data": { "entitled": true, "matched_plan_ids": ["UTRA-Rpl-..."] } }
A customer is entitled if any of their active or past_due recurring plans for your business was created from a product carrying that feature. past_duestill counts, the gateway is still retrying and access shouldn't be pulled mid-retry; cancelled/paused do not.

Pricing Tables

An embeddable, Stripe-style widget showing several prices side by side, each with its own "Choose" button.

Create one

View exampleHide example
POST https://api.ultraner.com/v1/business/pricing-tables
{ "name": "Plans", "price_ids": ["UTRA-Prc-aaa", "UTRA-Prc-bbb"] }

// 201 -> { "success": true, "data": { "id": "UTRA-Pgt-...", ... } }

The public, unauthenticated page lives at https://ultraner.com/pricing-table/{id}, embed it directly in an iframeon your own site or link to it. Clicking a price's "Choose" button mints a checkout session from that price and redirects the payer there, same as any other price-based checkout.

A pricing table always resolves fresh from the live prices, unlike a payment link this is a display surface, not something that charges money on its own, so an edited price shows its new amount immediately and an archived price is dropped from the view rather than shown broken.

GET /v1/business/pricing-tables lists them, PATCH edits the name or price list, DELETE archives (the public page stops resolving).

Promo Codes

A business can issue its own discount codes for its own checkout, the same way Ultraner issues them for the Developer Console subscription. Codes are scoped to the issuing business, a customer can never redeem one business's code on another's link.

Create and manage codes

View exampleHide example
POST https://api.ultraner.com/v1/business/promo-codes
X-API-Key: uk_live_...

{ "code": "SUMMER20", "discount_type": "percentage", "discount_value": 20 }

// fixed amount, in the business's own wallet currency
{
  "code": "OFF5K",
  "discount_type": "fixed_amount",
  "discount_value": 5000,
  "max_redemptions": 100,
  "valid_until": "2026-12-31T23:59:59Z"
}

GET /v1/business/promo-codes lists them, PATCH /v1/business/promo-codes/{id} pauses or resumes one ({ "is_active": false }), DELETE removes it.

Apply one at checkout

The payer applies a code on the /pay/{token} page directly, or you can preview one before charging:

View exampleHide example
POST https://api.ultraner.com/v0/pay/promo/validate
{ "token": "pl_...", "code": "SUMMER20" }

-> { "discount_amount": 2000, "final_amount": 8000, "currency": "TZS" }

Then pass promo_code on the charge itself (/v0/pay/checkout, /stripe/sessions, /paypal/orders). Validation is only a preview, the discount is recomputed server-side at charge time, so a tampered client response can never buy anything cheaper. A redemption only counts once the payment actually succeeds, so typing a code in and abandoning the payment never burns down its remaining uses.

Fixed-amount codes are currency-locked. A code created for TZS only applies to a payment in TZS, converting it would silently change the discount the merchant set and drift with the exchange rate. Percentage codes have no such restriction.

Ultraner's own fee is charged on money going out (withdrawal / disbursement), never on the amount collected, so a promo code simply reduces what the payer pays and what the business banks, it never changes any fee.

Escrow

Hold funds until a condition is met. Buyer locks the amount; seller receives only on release.

Create escrow

View exampleHide example
POST https://api.ultraner.com/v1/escrow

{
  "seller_phone": "255787654321",
  "amount": 120000,
  "condition": "Deliver item within 3 days"
}

// Response
{
  "success": true,
  "data": {
    "id": "esc_...",
    "escrow_code": "ESC-XXXXXX",
    "status": "locked",
    "amount": 120000
  }
}

Release / dispute

View exampleHide example
// Buyer releases to seller
POST https://api.ultraner.com/v1/escrow/{escrow_code}/release

// Buyer opens a dispute
POST https://api.ultraner.com/v1/escrow/{escrow_code}/dispute

// List all your escrows
GET https://api.ultraner.com/v1/escrow

Webhooks

Ultraner signs every outbound webhook with HMAC-SHA256 using your webhook secret. Always verify before processing.

Create a webhook

Webhooks are set up from your Developer Console, no API call needed. Open Console → Webhooks, click New Webhook, and:

  1. Enter your HTTPS endpoint URL.
  2. Click each event you want to receive, they're grouped by category (Payments, Recurring & subscriptions, Cross-border transfers, Disbursements, Bill payments), no need to type event names.
  3. Save. Ultraner shows you the signing secret once, copy it immediately, it isn't shown again.

You can edit a webhook's URL or event selection anytime from the same page, or pause/delete it, no need to recreate it to change which events it receives.

New to webhooks? How to receive a webhook on your server walks through creating a listener, getting a public URL, and testing it, step by step, with code in Node.js, Python, PHP, Ruby, and Go.

Verification (Node.js)

View exampleHide example
const crypto = require('crypto');

app.post('/webhooks/ultraner', (req, res) => {
  const sig = req.headers['x-ultraner-signature'];
  const body = JSON.stringify(req.body);
  const expected = 'sha256=' +
    crypto.createHmac('sha256', process.env.WH_SECRET)
          .update(body).digest('hex');

  if (sig !== expected) return res.status(401).end();

  const { event, data } = req.body;
  // handle event...
  res.status(200).end();
});

Events

payment.successpayment.failedrecurring.activatedrecurring.successrecurring.past_duerecurring.failedrecurring.cancelledcrossborder.successcrossborder.faileddisbursement.successdisbursement.failedbillpay.received

AzamPay callback verification

AzamPay uses RSA-SHA256 for its own callbacks. Ultraner verifies these before forwarding to your webhook.

View exampleHide example
// Signature covers: {utilityref}{externalreference}{transactionstatus}{operator}
// Public key: GET https://api.ultraner.com/v1/providers/azampay/public-key?format=Pem
//
// IMT callbacks use RSA-SHA512:
// Signature covers: {partnerSecret}{initiatorReferenceId}{pgReferenceId}