For developers
Airtime API your engineers will actually like.
A REST API for sending Zimbabwean airtime and ZESA tokens. Idempotency keys, HMAC-signed webhooks, multi-currency prepaid wallets, and a reconciler that resolves unknown states automatically. Built so you don't have to.
Authentication
Every request carries an Authorization: Api-Key atk_… header.
Get one from Settings → API keys in the dashboard.
Send a recharge
Post to POST /api/v1/orders with a target and an
amount. We work out what to deliver — a Zim mobile number becomes airtime,
an 11-digit number becomes a ZESA token. Pass agent_reference as your
idempotency key; duplicate requests with the same value return the same order.
$ curl -X POST https://jusa.localhost.co.zw/api/v1/orders \ -H "Authorization: Api-Key atk_…" \ -H "Content-Type: application/json" \ -d '{ "target": "0772279099", "amount": 5, "agent_reference": "ord_2026-05-07_12345", "custom_sms": "Your monthly comms allowance from ACME." }'
import requests, uuid resp = requests.post( "https://jusa.localhost.co.zw/api/v1/orders", headers={"Authorization": f"Api-Key {API_KEY}"}, json={ "target": "0772279099", "amount": 5, "agent_reference": "ord_" + uuid.uuid4().hex, }, timeout=30, ) resp.raise_for_status() order = resp.json() print(order["status"]) # "success" | "failed" | "unknown"
const resp = await fetch("https://jusa.localhost.co.zw/api/v1/orders", { method: "POST", headers: { "Authorization": `Api-Key ${process.env.API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ target: "0772279099", amount: 5, agent_reference: crypto.randomUUID(), }), }); const order = await resp.json();
Optional fields
currency—"USD"(default) or"ZWG". Picks the matching variant of the product we detected.product— override detection with an explicit slug (for example"econet-whatsapp","telone-broadband") for a specific catalogue item, including data bundles where they are published. List what's live viaGET /api/v1/products.custom_sms— a short branded message that rides on the recharge SMS. Supports{first_name},{month},{amount},{currency}.source_ref— your internal reference, echoed back on the order.
Detection rules: a Zim mobile prefix (071, 073,
077, 078) becomes airtime; a 10–11 digit number that isn't a
mobile becomes a ZESA token. Anything else returns 400 — pass product
explicitly in that case.
Endpoints
/api/v1/products — the published catalogue (slug, category, currency, min/max). Optional — most callers never need it./api/v1/orders — send a recharge. Idempotent on agent_reference./api/v1/orders/<agent_reference> — order status./api/v1/wallet — wallet balances./api/v1/wallet/topup — start a hosted-checkout top-up; returns a redirect URL./api/v1/wallet/ledger — append-only ledger entries./api/v1/bulk/preview — validate a CSV before you dispatch it./api/v1/bulk — create and run a batch./api/v1/campaigns/<id>/payouts — survey-completion payout, HMAC-signed./api/v1/zesa/rate — our estimate of the effective cost per unit, with its sample size and window. An estimate from purchases made here, never an official ZETDC or ZERA figure.Webhook callbacks
When an order finalizes — success, failed or unknown — we POST a JSON payload to your
tenant webhook URL with an HMAC-SHA256 signature in
X-Airtime-Signature.
# Verify in Python: import hmac, hashlib raw = request.body sig_header = request.headers["X-Airtime-Signature"] expected = hmac.new(WEBHOOK_SECRET.encode(), raw, hashlib.sha256).hexdigest() assert hmac.compare_digest(sig_header.replace("sha256=", ""), expected)
Failure semantics (read this once)
success
Provider confirmed. Wallet debited. provider_ref populated. Webhook fired.
failed → refunded
Provider rejected the recharge. Wallet auto-refunded. Status moves to refunded.
unknown
Network timeout or upstream hiccup. No refund. The reconciler runs
every 2 minutes, queries the provider, and resolves to success or
refunded failed. Plan retries around agent_reference,
never re-issue from the client side.
Test mode
In DEBUG=True, every top-up is forced to charge $0.01 so you
can iterate without burning USD. Recharges still hit the live provider and still consume
wallet balance.