# Xash Partners API: full documentation Every page of https://docs.partners.xash.co.zw in one file, for AI agents. The machine-readable spec is https://docs.partners.xash.co.zw/openapi.json. --- # Xash Partners API Sell Xash products from your own system: Econet, NetOne and Telecel airtime, data bundles, ZESA electricity and bills. You can also collect EcoCash, OMari and InnBucks payments from your customers into your Xash balance. Built for developers and their AI coding agents. Every page here is also available as Markdown, and [llms.txt](/llms.txt) gives an agent everything it needs in one file. ## How it works 1. **Sign up** at [partners.xash.co.zw](https://partners.xash.co.zw). You get a sandbox key straight away; sandbox keys never move real money. 2. **Quote, then sell.** Every sale is `POST /quotes` followed by `POST /transactions`. 3. **Listen for webhooks.** A `202 pending` answer is not a failure. The final status arrives by webhook, or you can fetch it. 4. **Go live.** Turn on two-factor sign-in, accept the terms and top up. Your live key works under Starter limits until your business is verified. ## Start here - [Quickstart](/quickstart): your first sandbox sale in about 10 minutes. - [INTEGRATE.md](/integrate): one file that tells your AI coding agent everything it needs. - [Sandbox numbers](/sandbox): test every outcome without real money. --- # Quickstart Make your first sandbox sale in about 10 minutes. Sandbox keys never move real money. ::: tip Using an AI coding agent? Give it [INTEGRATE.md](/integrate.md), or [llms-full.txt](/llms-full.txt) for everything. In the portal, **API keys → AI agent guide** gives you the same file with a fresh sandbox key already filled in. ::: ## 1. Get a sandbox key Sign up at [partners.xash.co.zw](https://partners.xash.co.zw/register). On **Home**, choose **Create sandbox key** and copy it. You'll only see it once. ```bash export XASH_API_KEY=xash_test_… ``` ## 2. Check the key ```bash curl -s https://partners.xash.co.zw/api/v1/me -H "Authorization: Bearer $XASH_API_KEY" ``` You get your partner code, `"environment": "test"` and the abilities this key has. ## 3. Find a product ```bash curl -s "https://partners.xash.co.zw/api/v1/products?category=airtime" -H "Authorization: Bearer $XASH_API_KEY" ``` Each product lists `required_fields` (for example `recipient.phone` or `recipient.meter`) and, for fixed products, your `price` after discount. ## 4. Quote the sale ```bash curl -s https://partners.xash.co.zw/api/v1/quotes \ -H "Authorization: Bearer $XASH_API_KEY" -H 'Content-Type: application/json' \ -d '{"product":"econet.airtime","amount":"5.00","recipient":{"phone":"263000000001"}}' ``` The quote holds the price for 5 minutes and tells you `total_debit`, the USD that will leave your balance. For ZESA and bills it also returns `customer.name`: show it to your customer first. ## 5. Make the sale ```bash curl -s https://partners.xash.co.zw/api/v1/transactions \ -H "Authorization: Bearer $XASH_API_KEY" -H 'Content-Type: application/json' \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"quote_id":"qt_…","reference":"ORDER-1001"}' ``` - `201` means a final status. Check `data.status`: `completed` (show `data.fulfilment` to your customer) or `failed` (your money is back). - `202` means `pending`. It is **not** a failure. Wait for the webhook, or `GET /transactions/{id}`. If the call times out, send the **same** request with the **same** `Idempotency-Key` and `reference`. You get the original sale back, never a second one. ## 6. Try the other outcomes Use the [sandbox numbers](/sandbox) to see a pending sale, a failure and a reversal, then [set a webhook](/webhooks) so you hear about every final status. When your integration handles all of them, you're ready to [go live](/going-live). --- # Integrate the Xash Partners API Instructions for a coding agent integrating your business with the Xash Partners API. Follow them exactly. Full reference: https://docs.partners.xash.co.zw/llms-full.txt (OpenAPI: https://docs.partners.xash.co.zw/openapi.json) ## Setup - Base URL: `https://partners.xash.co.zw/api/v1`. The same URL serves sandbox and live: the key decides. `xash_test_` keys only ever reach the simulator. - Sandbox key (no real money): `xash_test_REPLACE_WITH_YOUR_SANDBOX_KEY`. Put it in an environment variable `XASH_API_KEY`. Never commit it, and never ship it in an app or browser. - Send `Authorization: Bearer $XASH_API_KEY`, `Accept: application/json` and JSON bodies on every call. - Webhook secret: set the URL in the portal (Developers → Webhooks). It shows the `whsec_` secret once. Put it in `XASH_WEBHOOK_SECRET`. - Products you can sell: see GET /products. `GET /products` lists each one with its `recipient` fields and amount range. - Your balance: `GET /balances` → `data.available` (USD string). Every sale is paid from it. - Optional: the sandbox MCP server at `https://partners.xash.co.zw/mcp` takes the same sandbox key, for trying calls while you build (https://docs.partners.xash.co.zw/mcp). ## Every response Success: `{"success": true, "message": "…", "data": {…}}`. Lists add `meta.next_cursor`. Error: `{"success": false, "message": "…", "error": {"code": "…", "hint": "…", "docs_url": "…"}}`. A `VALIDATION_FAILED` or `RECIPIENT_INVALID` error also has `errors: {"recipient.meter": ["…"]}` naming the field. ## Rules you must follow 1. Every sale is two calls: `POST /quotes`, then `POST /transactions` with `{quote_id, reference}`. A quote is `data.id` (`qt_…`), lasts 5 minutes (`data.expires_at`) and can be used once. 2. Send your own unique `reference` (your order id) and an `Idempotency-Key` header (a new UUID per sale). If a call times out, fails to connect or returns `INTERNAL_ERROR`, send the SAME request with the SAME key and reference. You get the original sale back with `200` and header `Idempotency-Replayed: true`. Never create a new quote for an order that might already be sold. The same key with a different body is `409 IDEMPOTENCY_CONFLICT`. 3. Branch on `data.status`, never on the HTTP code: a new sale answers `201` or `202`, a replay answers `200`, and all three carry the same sale object. - `completed`: delivered. Show `data.fulfilment`. - `pending`: NOT a failure. The provider has it. Wait for the webhook, or poll `GET /transactions/{id}` every 10 s. Most settle within a minute; keep the order open and keep polling every minute for up to 24 hours, then ask Xash support. Never sell it again. - `failed`: nothing was delivered and the money is back. `data.failure_code` says why. - `reversed`: a completed sale was undone and the money returned. Undo the order. 4. Branch on `error.code`, never on `message`. Safe to retry later with the same request: `RATE_LIMITED`, `UPSTREAM_UNAVAILABLE`, `IDEMPOTENCY_IN_PROGRESS`, `INTERNAL_ERROR` (respect `Retry-After`). Fix the request first: `VALIDATION_FAILED`, `RECIPIENT_INVALID`, `QUOTE_EXPIRED`, `QUOTE_USED` (get a new quote). Needs a person: `INSUFFICIENT_BALANCE` (top up), `LIMIT_EXCEEDED`, `PARTNER_PAUSED`, `KEY_INVALID`. Full list: https://docs.partners.xash.co.zw/errors. 5. Amounts are strings with two decimals, like `"5.00"`, in USD. Phone numbers are `2637XXXXXXXX`. 6. `data.fulfilment` holds what the customer needs (ZESA `token` and `units`, voucher `pin`). It is `null` for airtime and bundles, which arrive on the phone. Print from `data.receipt`. 7. Verify every webhook before trusting it (see below). Ignore event IDs you have already handled. ## A sale, end to end ```bash curl -s https://partners.xash.co.zw/api/v1/quotes -H "Authorization: Bearer $XASH_API_KEY" -H 'Accept: application/json' -H 'Content-Type: application/json' \ -d '{"product":"econet.airtime","amount":"5.00","recipient":{"phone":"263000000001"}}' # → 200 {"data":{"id":"qt_…","total_debit":"4.85","expires_at":"…", …}} curl -s https://partners.xash.co.zw/api/v1/transactions -H "Authorization: Bearer $XASH_API_KEY" -H 'Accept: application/json' -H 'Content-Type: application/json' \ -H "Idempotency-Key: $(uuidgen)" -d '{"quote_id":"qt_…","reference":"ORDER-1001"}' # → 201 {"data":{"id":"tx_…","status":"completed","total_debit":"4.85","fulfilment":null,"receipt":{…}, …}} ``` Recipient fields: phone products take `{"phone": "…"}`, ZESA (`zetdc.prepaid`) takes `{"meter": "…"}`, bills take `{"account": "…"}`. For ZESA and bills the quote checks the meter or account and returns `data.customer.name`: show it to your customer before calling `POST /transactions`. ## Collections (take a payment from your customer) `POST /collections` with `{method: ecocash|omari|innbucks, amount, customer_phone, reference}` and an `Idempotency-Key`. It answers `202` with `data.status = pending` and `data.next_action = {type, message, …}`: - `type: approve_on_phone` (EcoCash): the customer approves on their phone. Poll `GET /collections/{id}` or wait for the webhook. - `type: submit_otp` (OMari): ask the customer for the OTP, then `POST /collections/{id}/confirm` with `{"otp": "…"}`. It returns the collection, usually `completed`; if still `pending`, poll as above. - `type: pay_code` (InnBucks): show the customer `next_action.code`, then poll. A `completed` collection is credited to your balance (`data.credit`). The money can only be spent on Xash products; collections are free. ## Webhooks Event types: `transaction.completed`, `transaction.failed`, `transaction.reversed`, `collection.completed`, `collection.failed`, `collection.reversed`, `webhook.test`. Body: ```json {"event_id":"evt_…","type":"transaction.completed","environment":"test","object_id":"tx_…","reference":"ORDER-1001","status":"completed","occurred_at":"…"} ``` Headers: `X-Xash-Event-Id`, `X-Xash-Event-Type`, `X-Xash-Timestamp`, `X-Xash-Signature`. The signature is the hex HMAC-SHA256 of `"{X-Xash-Timestamp}.{raw body}"` with your webhook secret. Reject it if it doesn't match or the timestamp is more than 5 minutes old. Answer `2xx` at once and do the work after; failures are retried for 72 hours. The body is thin: `GET` the object for full details. ```php $expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, getenv('XASH_WEBHOOK_SECRET')); $valid = hash_equals($expected, $signature) && abs(time() - (int) $timestamp) <= 300; ``` ## Test with these sandbox numbers | Recipient or customer_phone | Result | |---|---| | `263000000001` | Completed at once | | `263000000002` | Pending, then completed (about 30–60 s) | | `263000000003` | Pending, then failed (about 30–60 s) | | `263000000004` | Completed, then reversed after 2 minutes | | `263000000005` | Quote fails: `503 UPSTREAM_UNAVAILABLE` (safe to retry) | | meter `00000000001` | Customer `SANDBOX CUSTOMER` | | meter `00000000009` | `422 RECIPIENT_INVALID` | OMari sandbox OTP: `12345`. Your integration is done when it handles completed, pending → webhook, failed, reversed, a timeout replay and an OMari confirm. --- # MCP server for AI agents Connect your AI coding agent to the Xash sandbox over the Model Context Protocol. It can list products, quote and make test sales, run collections and read your recent API calls while it builds your integration. - **URL:** `https://partners.xash.co.zw/mcp` (Streamable HTTP) - **Auth:** `Authorization: Bearer xash_test_…`, a sandbox key from the portal - **Sandbox only:** live keys are refused, so an agent can never move real money through it. ## Connect Claude Code: ```bash claude mcp add --transport http xash-sandbox https://partners.xash.co.zw/mcp --header "Authorization: Bearer $XASH_API_KEY" ``` Other clients (Cursor, VS Code and others) take the same URL and header in their MCP settings: ```json { "mcpServers": { "xash-sandbox": { "type": "http", "url": "https://partners.xash.co.zw/mcp", "headers": { "Authorization": "Bearer xash_test_…" } } } } ``` ## Tools | Tool | What it does | |---|---| | `get_account` | Your partner, the key's abilities and the sandbox balance | | `list_products` | Products with prices and required recipient fields | | `lookup_mobile` | The operator of a phone number and what you can sell to it | | `quote` | Step 1 of a sale | | `sell` | Step 2: spends a quote. Reuse `reference` and `idempotency_key` to retry safely | | `get_transaction` | A sale's status, with token or PIN once completed | | `collect` | Starts an EcoCash, OMari or InnBucks collection | | `confirm_collection` | Submits an OMari OTP (sandbox OTP `12345`) | | `get_collection` | A collection's status | | `recent_requests` | Your latest sandbox API calls, to debug your integration | | `send_test_webhook` | Sends a signed test event to your sandbox webhook URL | | `sandbox_numbers` | The magic numbers that produce each outcome | Every tool calls the real API with your key, so results, errors and request logs are exactly what your own code will see. --- # Sales Every sale is the same two calls, whatever the product: airtime, data, ZESA, bills or vouchers. ## Quote, then sell 1. **`POST /quotes`** with `product`, `recipient` and, for ranged products, `amount` (USD) or `face_value`. The quote: - checks the recipient with the provider: a meter's name, a biller account; - prices the sale for you: `face_value`, `fee` and `total_debit` (what leaves your balance); - checks your balance and limits; - holds the price for **5 minutes**. 2. **`POST /transactions`** with `quote_id`, your `reference` and an `Idempotency-Key` header. Money is reserved and the sale is sent. A quote funds exactly one sale. Using it twice answers `409 QUOTE_USED`; after 5 minutes it's `409 QUOTE_EXPIRED`. ## Statuses | Status | Meaning | What you do | |---|---|---| | `pending` | Sent, outcome not final yet. Your money is reserved. | Wait for the webhook or poll. **Never buy again.** | | `completed` | Delivered. | Give your customer `fulfilment` (token, PIN, units). | | `failed` | Not delivered. Your money is back. | Tell your customer; they can try again with a new quote. | | `reversed` | Was completed, then the provider undid it. Your money is back. | Contact your customer. | ## Retrying safely Every sale needs a `reference` (your own id, e.g. your order number) and an `Idempotency-Key` (a UUID you generate). Together they make retries safe: - **Same key, same reference:** you get the original sale back with `200` and `Idempotency-Replayed: true`. Nothing is sold twice. - **Same key or reference with different details:** `409 IDEMPOTENCY_CONFLICT`. Use a new reference for a new sale. So if a request times out, or your server crashes mid-call, send exactly the same request again. ## Receipts Completed sales return `receipt`: everything you need to print for your customer (product, recipient, amount, token or PIN, units, Xash and provider references, time). You issue the receipt to your customer. ## Branches and tills Send `branch_id` (one of your [branch codes](https://partners.xash.co.zw/branches)) and `terminal_id` on the quote to tag the sale. An API key limited to a branch tags every sale with it automatically. The portal and `GET /transactions?branch_id=` report by branch. You have one balance for all branches. ## Limits Sandbox has no limits. Live sales are capped per sale and per day by your tier; see [Going live](/going-live). Over a limit you get `409 LIMIT_EXCEEDED` with `error.limit` naming which one. --- # Collections Take a payment from your customer's EcoCash, OMari or InnBucks into your Xash balance. - Collections are **free**. - Collected money is **spend-only**: it buys Xash products, and can't be withdrawn. ## Start a collection ```bash curl -s https://partners.xash.co.zw/api/v1/collections \ -H "Authorization: Bearer $XASH_API_KEY" -H 'Content-Type: application/json' \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"method":"ecocash","amount":"5.00","customer_phone":"263000000001","reference":"INV-2001"}' ``` It answers `202 pending` with `next_action`, which tells you what happens next: | Method | `next_action.type` | What to do | |---|---|---| | `ecocash` | `approve_on_phone` | Your customer approves the prompt with their PIN. Wait. | | `omari` | `submit_otp` | Ask your customer for the OTP they received, then `POST /collections/{id}/confirm` with `{"otp": "…"}`. | | `innbucks` | `pay_code` | Show your customer `next_action.code`. They pay it in the InnBucks app. | When the customer pays, the collection becomes `completed`, the amount is added to your balance, and you get a `collection.completed` webhook. Unpaid collections fail with `EXPIRED` after 15 minutes. ## Paying in ZWG Send `"currency": "ZWG"` to let your customer pay in ZWG. They're charged at Xash's published rate (`charge_amount`, `rate`), and you're credited the USD `amount`. ::: info Sandbox only for now Live collections are in USD at launch. A live ZWG request answers `409 COLLECTION_METHOD_UNAVAILABLE` with `error.reason: zwg_not_live`. ::: ## Reversals If a provider reverses a payment after it completed, the amount is taken back from your balance, even if that takes it below zero. A negative balance pauses sales and collections in that environment (`403 PARTNER_PAUSED`) until you top up. You get `collection.reversed` and `partner.paused` webhooks. ## Limits Live collections are capped per collection and per day by your tier; see [Going live](/going-live). --- # Webhooks Webhooks tell your server when a sale or collection reaches a final status, so you don't have to poll. Set the URL in the portal: **Developers → Webhooks**. You get a `whsec_` secret **once**; keep it on your server. ## Events | Event | When | |---|---| | `transaction.completed` · `transaction.failed` · `transaction.reversed` | A sale's final status | | `collection.completed` · `collection.failed` · `collection.reversed` | A collection's final status | | `partner.paused` | A reversal took your balance below zero | | `webhook.test` | You pressed **Send test event** or called `POST /webhooks/test` | The body is thin. `GET` the object for full details: ```json {"event_id":"evt_…","type":"transaction.completed","environment":"test","object_id":"tx_…","reference":"ORDER-1001","status":"completed","occurred_at":"2026-10-17T14:05:00+02:00"} ``` ## Verify every request Headers: `X-Xash-Event-Id`, `X-Xash-Event-Type`, `X-Xash-Timestamp`, `X-Xash-Signature`. The signature is the **hex HMAC-SHA256** of `"{X-Xash-Timestamp}.{raw body}"` with your secret. Use the raw body exactly as received, before parsing. Reject the request if the signature doesn't match or the timestamp is more than 5 minutes old. ::: code-group ```php [PHP] $raw = file_get_contents('php://input'); $ts = $_SERVER['HTTP_X_XASH_TIMESTAMP'] ?? ''; $expected = hash_hmac('sha256', $ts.'.'.$raw, getenv('XASH_WEBHOOK_SECRET')); if (! hash_equals($expected, $_SERVER['HTTP_X_XASH_SIGNATURE'] ?? '') || abs(time() - (int) $ts) > 300) { http_response_code(401); exit; } ``` ```js [Node] import crypto from 'node:crypto'; // rawBody: the request body as a Buffer, before JSON parsing const ts = req.get('X-Xash-Timestamp'); const expected = crypto.createHmac('sha256', process.env.XASH_WEBHOOK_SECRET).update(`${ts}.${rawBody}`).digest('hex'); const given = req.get('X-Xash-Signature') ?? ''; const ok = given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected)) && Math.abs(Date.now() / 1000 - Number(ts)) <= 300; ``` ```python [Python] import hmac, hashlib, os, time expected = hmac.new(os.environ["XASH_WEBHOOK_SECRET"].encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest() ok = hmac.compare_digest(expected, signature) and abs(time.time() - int(ts)) <= 300 ``` ::: ## Delivery - Answer with any `2xx` within 10 seconds. Do the slow work afterwards. - Failures are retried after 30 s, 1 min, 2 min… up to once an hour, for 72 hours. - The same event can arrive more than once. Ignore `event_id`s you've already handled. - The portal shows every delivery, with **Redeliver**. - Webhook URLs must be public `https://` addresses on port 443. --- # Sandbox Sandbox keys (`xash_test_…`) use simulated providers. No real money moves, and no real phone is ever contacted. Your sandbox balance starts at USD 1,000.00; **Reset sandbox balance** on Home puts it back. ## Sales | Recipient | Result | |---|---| | `263000000001`, or any other valid number | `201 completed` at once | | `263000000002` | `202 pending`, then `completed` after 30 seconds | | `263000000003` | `202 pending`, then `failed` (`PROVIDER_REJECTED`) after 30 seconds | | `263000000004` | `201 completed`, then `reversed` after 2 minutes | | `263000000005` | The quote fails with `503 UPSTREAM_UNAVAILABLE` (nothing was sent; safe to retry) | | meter `00000000001`, account `TEST-OK` | The quote returns customer `SANDBOX CUSTOMER` | | meter `00000000009`, account `TEST-BAD` | `422 RECIPIENT_INVALID` | ZESA sales return the sandbox token `0000 1111 2222 3333 4444`. ## Collections | customer_phone | Result | |---|---| | `263000000001`, or any other | `completed` on the first status check | | `263000000002` | `completed` after 30 seconds | | `263000000003` | `failed` (`CUSTOMER_DECLINED`) after 30 seconds | | `263000000004` | `completed`, then `reversed` after 2 minutes | The OMari OTP is always `12345`. Unpaid collections expire after 15 minutes. Pending results settle when you `GET` the object, or within a minute on their own. --- # Going live Live keys (`xash_live_…`) sell for real, from your prepaid USD balance. ::: info Opening soon Live sales open after the pilot. Everything on this page works the same in sandbox, so build against it now. ::: ## Steps The owner does these in the portal under **Go live**. No call or paperwork is needed to start on the Starter tier. 1. **Turn on two-factor sign-in** for the owner account. 2. **Accept the [partner terms](/terms).** 3. **Request live access.** 4. **Top up** by bank transfer with your partner code (`XP-…`) as the reference. Xash credits it once the money arrives, and live switches on with your first credited top-up. Mobile money and USDT top-ups are coming. 5. **Create a live key.** Only the owner can, and live keys always expire (within a year). At launch, live sales cover Econet, NetOne and Telecel airtime, Econet and NetOne bundles, and ZESA tokens. `GET /products` with a live key shows `available: false` for anything not open yet (bills, NetOne vouchers): build for them in sandbox now. ## Tiers and limits | Tier | How you get it | Per sale | Sales per day | Per collection | Collections per day | |---|---|---|---|---|---| | Starter | The steps above | $20 | $100 | $50 | $200 | | Verified | Business verification approved, IP allowlist set | $500 | $5,000 | $1,000 | $10,000 | | Enterprise | Agreed with Xash | custom | custom | custom | custom | Collections count toward the daily limit from the moment they're accepted, pending ones included; a failed collection gives its amount back. A day runs midnight to midnight, Harare time, and a collection belongs to the day it was accepted. Over a limit, you get `409 LIMIT_EXCEEDED` with `error.limit` naming which one. `GET /me` shows each limit and how much you've used today. ## Business verification The owner sends the documents in the portal under **Verify business**. Review usually takes 1–2 working days, and approval moves you to the Verified tier. - **Sole traders:** national ID, a selfie holding it, proof of address (under 3 months old). - **Companies:** certificate of incorporation, CR14, a director's national ID, proof of business address. Only the Xash staff who review verification can open your documents, and every opening is logged. ## Security - Keep live keys on your server only. - Limit each key to the abilities, and if useful the branch, it needs. - From the Verified tier, live keys only work from the IP addresses you allow. --- # Partner terms The Xash partner terms are being finalised. They will be published on this page, with their version, before live access opens. Sandbox use is free and moves no real money. --- # Coming from the Xash vend API This page is for you if you call `app.xash.co.zw/api/v1` today with a server token (`svt_…`). That is the API documented at docs.xash.co.zw. It shows what is different in the Partners API and which new call replaces each old one. The Partners API is a separate product. It has its own sign-up, keys, balance and base URL: `https://partners.xash.co.zw/api/v1`. Moving over does not change your server token or your app.xash.co.zw wallet. ## What changes ### A separate partner account and balance You sign up at [partners.xash.co.zw](https://partners.xash.co.zw) as a business. You get a partner account with its own prepaid balance. - The balance is in USD only. There is no ZWG wallet. A ZWG product is priced in USD, and the quote tells you the USD amount. - It is not your app.xash.co.zw wallet. Money in your old wallet stays there. - You fund it with a top-up (mobile money, USDT or bank transfer), or with money you collect from customers. ### Keys instead of server tokens You create API keys in the portal, not with `POST /auth/server-tokens`. There is no login, PIN or password call in the API. - `xash_test_…` keys use the sandbox. They never move real money, and you get one as soon as you sign up. - `xash_live_…` keys sell for real. They work after you finish go-live in the portal. - Each key has abilities, such as `transactions:write` or `balance:read`. Give each system only what it needs. The old API had no sandbox, so every test sold real airtime. Build and test against the sandbox first. ### Quote, then sell The old API sold in one call. Every sale is now two calls: 1. `POST /quotes` checks the recipient and returns the price, the fee and the `total_debit`. For ZESA and bills it also returns the customer name. A quote lasts 5 minutes. 2. `POST /transactions` spends the quote. This replaces the separate "check account" calls. `electricity/check-account` and `dstv/lookup` are now just the quote. ### A reference and an Idempotency-Key on every sale Every `POST /transactions` needs: - a `reference` in the body: your own unique id for the sale, such as your order number; - an `Idempotency-Key` header: a UUID you make for that sale. If your request times out, send the same request again with the same body and the same key. You get the original sale back with `200`. You never pay twice. The old API had no such protection. Sending `airtime/direct` again after a timeout could sell twice. With the new API, retrying is the right thing to do. ### 202 means pending, not failed - `201` with status `completed` means it was delivered. - `202` with status `pending` means the outcome is not final yet. The money is reserved. Wait for the webhook, or `GET /transactions/{id}`. Never buy again. - Only status `failed` means nothing was delivered. The money is then back in your balance. The API never answers `502` or `504`. ### One error shape The old API mostly answered `400` with only a `message`. Now every error looks like this: ```json { "success": false, "message": "Your balance is too low for this.", "error": { "code": "INSUFFICIENT_BALANCE", "hint": "Top up your balance, then make a new quote.", "docs_url": "https://docs.partners.xash.co.zw/errors#insufficient_balance" } } ``` Branch on `error.code`, never on `message`. The codes never change. The HTTP status also means something now: `402` is a low balance, `409` is a conflict and `422` is bad input. See [Errors](/errors) for every code. ### Webhooks instead of polling The old API made you poll. Now you set a webhook URL in the portal, and we send you an event when a sale, collection or top-up finishes, such as `transaction.completed` or `collection.completed`. - Each event is signed. Check the `X-Xash-Signature` header: it is the hex HMAC-SHA256 of `{X-Xash-Timestamp}.{raw body}`, made with your webhook secret. - Reject events with a timestamp older than 5 minutes. - Use `X-Xash-Event-Id` to ignore an event you have already handled. - Events are short. `GET` the record for the full details. `POST /webhooks/test` sends you a signed test event. ### Collections are spend-only You can still take EcoCash, OMari and InnBucks payments from your customers with `POST /collections`. The money goes into your partner balance. That money can only buy Xash products. It is never paid out to you, and there is no refund call. Collections are free. To top up your own balance, use `POST /fundings` instead. ## Old endpoint to new call Old paths are under `https://app.xash.co.zw/api/v1`. New paths are under `https://partners.xash.co.zw/api/v1`. Product codes are dotted and lowercase, such as `econet.airtime` or `zetdc.prepaid`. Get the full list, with your price and the fields each product needs, from `GET /products`. ### Account and keys | Old | New | |---|---| | `POST /auth/register`, `set-password`, `login`, PIN calls, `change-password`, `create-business` | Sign up and manage users in the [portal](https://partners.xash.co.zw). No API call. | | `GET/POST/DELETE /auth/server-tokens` | Create and revoke API keys in the portal. No API call. | | `GET /auth/profile`, `GET /profile` | `GET /me` | | `GET /wallet`, `GET /wallet/{currency}` | `GET /balances` (USD only) | | `GET /services` | `GET /products` | | `GET /pos/branches/{identifier}` | `GET /branches` | | `POST /pos/branches` | Create branches in the portal. Tag a sale with `branch_id`. | ### Selling | Old | New | |---|---| | `GET /airtime/carriers` | `GET /products?category=airtime`, or `POST /lookups/mobile` to find the operator for a number | | `POST /airtime/direct` | `POST /quotes` with e.g. `econet.airtime`, then `POST /transactions` | | `GET /airtime/direct/{carrier}/values`, `GET /airtime/voucher/{carrier}/values` | `GET /products?category=voucher` | | `POST /airtime/direct/voucher/{carrier}`, `POST /airtime/voucher/{carrier}/buy` | `POST /quotes`, then `POST /transactions`. The PIN is in `fulfilment`. One voucher per sale: there is no `quantity`. | | `GET /bundles` | `GET /products?category=data` | | `POST /bundles/buy/{bundle}` | `POST /quotes` with the bundle's code, e.g. `netone.bundle.whatsapp_1gb`, then `POST /transactions` | | `POST /bundles/voucher/buy/{bundle}` | `POST /quotes`, then `POST /transactions`. One voucher per sale. | | `POST /electricity/check-account` | `POST /quotes` with `zetdc.prepaid`. The quote returns the customer name. | | `POST /electricity/buy-tokens` | `POST /transactions` with that quote. The token is in `fulfilment`. | | `GET /dstv/packages` | `GET /products?category=bill` | | `POST /dstv/lookup` | `POST /quotes` | | `POST /dstv/purchase` | `POST /transactions` | | `GET /wifi/vouchers` | `GET /products?category=voucher` | | `POST /wifi/vouchers/buy` | `POST /quotes`, then `POST /transactions`. One voucher per sale. | | `GET /wifi/vouchers/purchases/{reference}` | `GET /transactions?reference=…` or `GET /transactions/{id}` | Only products in your `GET /products` list are on sale to you. ### Money in | Old | New | |---|---| | `POST /ecocash/pay`, `/innbucks/pay`, `/omari/pay`, `/payments/receive` to take a customer's payment | `POST /collections` with `method` `ecocash`, `innbucks` or `omari`. Send `currency: "ZWG"` for a ZWG payment. | | The same calls, to load your own wallet | `POST /fundings` | | `POST /crypto/pay` | `POST /fundings` (USDT) | | `POST /omari/confirm` | `POST /collections/{id}/confirm` | | `GET /{method}/poll/{payment}` | The `collection.completed` or `funding.credited` webhook, or `GET /collections/{id}` and `GET /fundings/{id}` | | `POST /ecocash/direct/charge`, `GET /ecocash/direct/transaction` | `POST /collections`, `GET /collections/{id}` | | `POST /card/pay` | No equivalent. There are no card payments yet. | | `POST /ecocash/direct/refund` | No equivalent. Collected money is spend-only. | | `GET /ecocash/lookup`, `POST /{method}/poll/{payment}/report-debit` | No equivalent yet. | ### Reports | Old | New | |---|---| | `GET /reports/mini/{currency}/{range}`, `GET /reports/history/{currency}` | `GET /statement` for balance changes. `GET /transactions` and `GET /collections` to list or find records. | | `GET /reports/transaction/{transaction}` | `GET /transactions/{id}` | | `GET /reports/commission-overview/{currency}`, `GET /reports/commission/{currency}/{range}` | No equivalent. There is no commission: your discount is already in the price you pay. | ### Everything else | Old | New | |---|---| | `POST /transfer`, `POST /transfer/confirm/{transfer}` | No equivalent. You cannot move balance to another account. | | `POST /notifications/sms` | No equivalent. | | `/users/…`, `/admin/…`, `/providers-balance` | No equivalent. These were for Xash's own systems. | ## Example: an airtime sale Both examples use a sandbox number in the `263000000…` range. The amounts, ids and messages are examples. Do not run the old example: the old API has no sandbox. ### Before One call. A timeout leaves you guessing, and retrying could sell twice. ```bash curl https://app.xash.co.zw/api/v1/airtime/direct \ -H "Authorization: Bearer $XASH_SERVER_TOKEN" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{"mobile_phone": "263000000001", "amount": 1, "currency": "USD"}' ``` ```json { "success": true, "message": "Recharge to 263000000001 was successful", "data": { "name": "Econet", "amount": 1, "currency": "USD", "mobile_phone": "263000000001", "status": "success", "balance": { "balance": 99, "currency": "USD", "profit_on_hold": 0 }, "commission": "USD0.05" } } ``` ### After **1. Quote.** Amounts are strings with two decimal places. ```bash curl https://partners.xash.co.zw/api/v1/quotes \ -H "Authorization: Bearer $XASH_KEY" \ -H "Content-Type: application/json" \ -d '{"product": "econet.airtime", "amount": "1.00", "recipient": {"phone": "263000000001"}}' ``` ```json { "success": true, "message": "Quote created.", "data": { "id": "qt_01JB4Y3KQ2N8F7", "product": "econet.airtime", "customer": null, "face_value": "1.00", "face_currency": "USD", "fee": "0.00", "total_debit": "0.95", "rate": null, "expires_at": "2026-10-17T14:10:00+02:00" } } ``` **2. Sell.** Use your own order number as the `reference`, and a new UUID as the `Idempotency-Key`. Save both before you send. ```bash curl https://partners.xash.co.zw/api/v1/transactions \ -H "Authorization: Bearer $XASH_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 3f1c9a52-6d0e-4c7b-9a51-2e8d4b7f0c13" \ -d '{"quote_id": "qt_01JB4Y3KQ2N8F7", "reference": "order-10045"}' ``` ```json { "success": true, "message": "Sale completed.", "data": { "id": "tx_01JB4Y5R9M2C6D", "reference": "order-10045", "status": "completed", "product": "econet.airtime", "total_debit": "0.95", "balance_after": "99.05", "created_at": "2026-10-17T14:05:09+02:00", "completed_at": "2026-10-17T14:05:11+02:00" } } ``` The full record has more fields, such as `fulfilment` (the token or PIN) and `receipt`. What to do with each answer: - `201`, status `completed`: done. Show your customer the receipt. - `202`, status `pending`: wait for the `transaction.completed` or `transaction.failed` webhook. Do not sell again. - A timeout or a network error: send step 2 again with the same body and the same `Idempotency-Key`. - An error: read `error.code` and follow `error.hint`. For example, `QUOTE_EXPIRED` means make a new quote. ## Next steps 1. Sign up at [partners.xash.co.zw](https://partners.xash.co.zw) and copy your `xash_test_` key. 2. Read the [endpoints](/reference/endpoints) and [errors](/errors) reference. AI agents can read [llms.txt](/llms.txt). 3. Move one product at a time, and test each in the sandbox. 4. Go live in the portal, top up, and switch to your `xash_live_` key. --- # Endpoints Base URL: `https://partners.xash.co.zw/api/v1`. Send `Authorization: Bearer ` and JSON bodies. Full schemas: [openapi.json](/openapi.json). ## Account | Method | Path | What it does | Ability | |---|---|---|---| | `GET` | `/me` | Who am I. Your partner, the environment this key is for, your tier, abilities and how much of each limit is used today. | any | | `GET` | `/balances` | Balance. Available and reserved balance in USD. | `balance:read` | | `GET` | `/statement` | Statement. Ledger entries (top-ups, sales, collections, releases, reversals) with the running balance, newest first. Pages of up to 100; pass meta.next_cursor as ?cursor= for the next page. | `balance:read` | ## Catalogue | Method | Path | What it does | Ability | |---|---|---|---| | `GET` | `/products` | List products. Everything you can sell, with your price, limits and the recipient fields each product needs. Filter with category, operator and currency. | `catalogue:read` | | `GET` | `/products/{code}` | One product. A single product by its code, e.g. econet.airtime. | `catalogue:read` | | `POST` | `/lookups/mobile` | Identify a phone number. Returns the operator for a phone number and the products you can sell to it. | `catalogue:read` | | `GET` | `/branches` | List branches. Your branches, for tagging sales and collections. | `catalogue:read` | ## Sales | Method | Path | What it does | Ability | |---|---|---|---| | `POST` | `/quotes` | Quote a sale. Step 1 of every sale. Validates the recipient (meter name, biller account) and returns the price, fee and total debit. Quotes last 5 minutes. | `transactions:write` | | `POST` | `/transactions` | Make a sale. Step 2: spend a quote. 201 means it reached a final status (check data.status: completed or failed). 202 means pending: wait for the webhook or GET it, and never buy again. Replaying the same reference and Idempotency-Key returns the original sale with 200 and Idempotency-Replayed: true. Needs `Idempotency-Key`. | `transactions:write` | | `GET` | `/transactions` | List or find sales. Filter by reference, status, branch_id, from and to. | `transactions:read` | | `GET` | `/transactions/{id}` | One sale. The authoritative status of a sale, with the token or PIN and receipt data once completed. | `transactions:read` | ## Collections | Method | Path | What it does | Ability | |---|---|---|---| | `POST` | `/collections` | Collect from a customer. Starts an EcoCash, OMari or InnBucks payment from your customer into your balance. Usually 202 pending; next_action says what happens next. Collections are free, and the money is spend-only: it can only buy Xash products. Replays return 200 with Idempotency-Replayed: true. Needs `Idempotency-Key`. | `collections:write` | | `POST` | `/collections/{id}/confirm` | Confirm an OMari collection. Submit the OTP your customer received. | `collections:write` | | `GET` | `/collections` | List or find collections. Filter by reference, status, method, branch_id, from and to. | `collections:read` | | `GET` | `/collections/{id}` | One collection. The authoritative status of a collection. | `collections:read` | ## Top-ups | Method | Path | What it does | Ability | |---|---|---|---| | `POST` | `/fundings` | Top up by mobile money or USDT. Starts a top-up of your own balance. Bank transfers are matched by Finance using your partner code as the payment reference. Needs `Idempotency-Key`. | `funding:write` | | `GET` | `/fundings/{id}` | One top-up. The status of a top-up. | `funding:write` | ## Webhooks | Method | Path | What it does | Ability | |---|---|---|---| | `POST` | `/webhooks/test` | Send a test event. Sends a signed webhook.test event to this environment's webhook URL now and tells you what your endpoint answered. Use it to check your signature verification. | any | --- # Errors Every error has the same shape. Branch on `error.code`; it never changes. ```json { "success": false, "message": "Your balance is too low for this.", "error": { "code": "INSUFFICIENT_BALANCE", "hint": "Top up your balance, then make a new quote.", "docs_url": "https://docs.partners.xash.co.zw/errors#insufficient_balance" } } ``` The API never answers 502 or 504. When a provider's answer is unknown, you get `202` with status `pending`, not an error. | Code | HTTP | Meaning | What to do | |---|---|---|---| | `KEY_INVALID` | 401 | The API key is missing, wrong, expired or revoked. | Send Authorization: Bearer xash_test_... or xash_live_... Create a new key in Developers → API keys if needed. | | `KEY_ENVIRONMENT_MISMATCH` | 401 | This key belongs to the other environment for this request. | Use a xash_test_ key for sandbox and a xash_live_ key for live. | | `ABILITY_MISSING` | 403 | This key is not allowed to do that. | Create a key that has the ability named in error.ability. | | `IP_NOT_ALLOWED` | 403 | Requests from this IP address are not allowed for this partner. | Add your server IP in Settings → Security, or call from an allowed IP. | | `LIVE_NOT_ENABLED` | 403 | Live sales are not enabled for this partner yet. | Finish go-live in the portal: turn on 2FA, accept the terms and make a first top-up. | | `PARTNER_PAUSED` | 403 | This partner is paused. | Check error.reason. If your balance is below zero, top up. Otherwise contact support. | | `NOT_FOUND` | 404 | Nothing exists at this address. | Check the path against https://docs.partners.xash.co.zw/openapi.json. | | `METHOD_NOT_ALLOWED` | 405 | This address does not accept that HTTP method. | Check the HTTP method against the API reference. | | `VALIDATION_FAILED` | 422 | Some fields are missing or invalid. | Fix the fields listed in errors and send the request again. | | `RECIPIENT_INVALID` | 422 | The provider rejected this phone number, meter or account. | Ask your customer to check the number, meter or account, then make a new quote. | | `INSUFFICIENT_BALANCE` | 402 | Your balance is too low for this. | Top up your balance, then make a new quote. | | `LIMIT_EXCEEDED` | 409 | This would go over one of your limits. | error.limit says which limit. Wait for it to reset, or ask support to raise it. | | `QUOTE_EXPIRED` | 409 | This quote has expired. | Make a new quote. Quotes last 5 minutes. | | `QUOTE_USED` | 409 | This quote has already been used. | Look up the existing transaction by your reference instead of buying again. | | `IDEMPOTENCY_CONFLICT` | 409 | This reference or Idempotency-Key was already used with different details. | Use a new reference for a new sale. To retry the same sale, send exactly the same body and key. | | `IDEMPOTENCY_IN_PROGRESS` | 409 | A request with this Idempotency-Key is still being processed. | Wait a second and send the same request again. | | `PRODUCT_UNAVAILABLE` | 409 | This product is paused right now. | Try again later, or offer your customer another product. | | `COLLECTION_METHOD_UNAVAILABLE` | 409 | This payment method is paused right now. | Try again later, or ask your customer to pay another way. | | `OTP_INVALID` | 422 | The OTP is wrong or has expired. | Ask your customer for the latest OTP and confirm again. | | `RATE_LIMITED` | 429 | Too many requests. | Wait for the Retry-After seconds, then retry. | | `VELOCITY_LIMITED` | 429 | Too many sales in a short time. | Wait for the Retry-After seconds, then retry. | | `UPSTREAM_UNAVAILABLE` | 503 | The provider could not be reached. Nothing was sent. | It is safe to send the same request again after Retry-After seconds. | | `INTERNAL_ERROR` | 500 | Something went wrong on our side. | Retry with the same Idempotency-Key. If it keeps failing, contact support with the X-Request-Id. |