Overview
Built for AI agents, LLMs, and vibe-coding tools too, not just humans.
View as Markdown Download .mdUltraner 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.
API routes
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 moneyuk_test_SandboxSimulated transactionsView 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

4242 4242 4242 4242
Succeeds

4000 0000 0000 0002
Generic decline

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

3405 746585 43130
Exp 08/2031 · CVC any 3 digits

4032 0305 7755 0040
Exp 07/2031 · CVC any 3 digits

5110 9205 7115 9602
Exp 08/2031 · CVC any 3 digits

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.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.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.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 successEnds in 700000002Insufficient funds (fails)Ends in 700000003Times out, never resolves, stays pendingamount 1-100 (smallest unit)Simulated provider outage (5xx)Any other numberSucceeds after a random 3-8s delaySuccessful 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-keyBank 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 successEnds in 700000002Insufficient funds (fails)Ends in 700000003Times out, never resolves, stays pendingamount 1-100 (smallest unit)Simulated provider outage (5xx)Any other numberSucceeds after a random 3-8s delayDisbursement
Send funds from your Ultraner wallet to any mobile money account in Tanzania. Supported operators: Airtel, Azampesa, Tigo.
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})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})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 successEnds in 700000002Insufficient funds (fails)Ends in 700000003Times out, never resolves, stays pendingamount 1-100 (smallest unit)Simulated provider outage (5xx)Any other numberSucceeds after a random 3-8s delaySelcom, MNO, Bank & Card
Selcom provides mobile money, bank transfer, disbursement, bill payment, and card collection for Tanzania. Supported MNO operators: Vodacom, Airtel, Tigo, Halotel.
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 successEnds in 700000002Insufficient funds (fails)Ends in 700000003Times out, never resolves, stays pendingamount 1-100 (smallest unit)Simulated provider outage (5xx)Any other numberSucceeds after a random 3-8s delayDisbursement
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"
}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.
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 successEnds in 700000002Insufficient funds (fails)Ends in 700000003Times out, never resolves, stays pendingamount 1-100 (smallest unit)Simulated provider outage (5xx)Any other numberSucceeds after a random 3-8s delayHosted 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"
}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.
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.
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.
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 inlineCheck 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 successEnds in 700000002Insufficient funds (fails)Ends in 700000003Times out, never resolves, stays pendingamount 1-100 (smallest unit)Simulated provider outage (5xx)Any other numberSucceeds after a random 3-8s delayUniversal 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 successEnds in 700000002Insufficient funds (fails)Ends in 700000003Times out, never resolves, stays pendingamount 1-100 (smallest unit)Simulated provider outage (5xx)Any other numberSucceeds after a random 3-8s delaymethod: "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.
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.
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 paymentsManage, 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 attemptThe 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.
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.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.
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 checkoutGET /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-..." }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 }
]
}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} // detachCreate 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-..."] } }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.
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.
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/escrowWebhooks
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:
- Enter your HTTPS endpoint URL.
- 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.
- 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.
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
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}