Appearance
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 variableXASH_API_KEY. Never commit it, and never ship it in an app or browser. - Send
Authorization: Bearer $XASH_API_KEY,Accept: application/jsonand JSON bodies on every call. - Webhook secret: set the URL in the portal (Developers → Webhooks). It shows the
whsec_secret once. Put it inXASH_WEBHOOK_SECRET. - Products you can sell: see GET /products.
GET /productslists each one with itsrecipientfields 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/mcptakes 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
- Every sale is two calls:
POST /quotes, thenPOST /transactionswith{quote_id, reference}. A quote isdata.id(qt_…), lasts 5 minutes (data.expires_at) and can be used once. - Send your own unique
reference(your order id) and anIdempotency-Keyheader (a new UUID per sale). If a call times out, fails to connect or returnsINTERNAL_ERROR, send the SAME request with the SAME key and reference. You get the original sale back with200and headerIdempotency-Replayed: true. Never create a new quote for an order that might already be sold. The same key with a different body is409 IDEMPOTENCY_CONFLICT. - Branch on
data.status, never on the HTTP code: a new sale answers201or202, a replay answers200, and all three carry the same sale object.completed: delivered. Showdata.fulfilment.pending: NOT a failure. The provider has it. Wait for the webhook, or pollGET /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_codesays why.reversed: a completed sale was undone and the money returned. Undo the order.
- Branch on
error.code, never onmessage. Safe to retry later with the same request:RATE_LIMITED,UPSTREAM_UNAVAILABLE,IDEMPOTENCY_IN_PROGRESS,INTERNAL_ERROR(respectRetry-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. - Amounts are strings with two decimals, like
"5.00", in USD. Phone numbers are2637XXXXXXXX. data.fulfilmentholds what the customer needs (ZESAtokenandunits, voucherpin). It isnullfor airtime and bundles, which arrive on the phone. Print fromdata.receipt.- 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. PollGET /collections/{id}or wait for the webhook.type: submit_otp(OMari): ask the customer for the OTP, thenPOST /collections/{id}/confirmwith{"otp": "…"}. It returns the collection, usuallycompleted; if stillpending, poll as above.type: pay_code(InnBucks): show the customernext_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.