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

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

GET/api/v1/products — the published catalogue (slug, category, currency, min/max). Optional — most callers never need it.
POST/api/v1/orders — send a recharge. Idempotent on agent_reference.
GET/api/v1/orders/<agent_reference> — order status.
GET/api/v1/wallet — wallet balances.
POST/api/v1/wallet/topup — start a hosted-checkout top-up; returns a redirect URL.
GET/api/v1/wallet/ledger — append-only ledger entries.
POST/api/v1/bulk/preview — validate a CSV before you dispatch it.
POST/api/v1/bulk — create and run a batch.
POST/api/v1/campaigns/<id>/payouts — survey-completion payout, HMAC-signed.
GET/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.