Skip to content

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_phoneResult
263000000001Completed at once
263000000002Pending, then completed (about 30–60 s)
263000000003Pending, then failed (about 30–60 s)
263000000004Completed, then reversed after 2 minutes
263000000005Quote fails: 503 UPSTREAM_UNAVAILABLE (safe to retry)
meter 00000000001Customer SANDBOX CUSTOMER
meter 00000000009422 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.

Xash Partners API. Sandbox keys never move real money.