Dataspark Partner APIv1

Dataspark Partner API

Buy mobile data bundles from your own website, app or server. Your customers pay you; your server buys the bundle from us with one API call and pays from your Dataspark wallet at your own prices.

  • Works from any language that can send an HTTPS request: PHP, Node.js, Python, Java, C#, Ruby, Go, WordPress/WooCommerce and more. Complete, tested examples are at the bottom of this page.
  • Orders are accepted immediately and delivered in the background. You check the status when you like.
  • If a request times out you can safely send it again with the same client_reference: you are never charged twice.
Base URL: https://dataspark.store/api/v1   All requests and responses are JSON. All amounts are in Ghana cedis (GHS).

Quick start

  1. Log in to your dashboard, open Developer API, accept the terms and click Create key. Copy the key straight away: it is shown only once.
  2. Keep the key on your server, for example in an environment variable. Never put it in a website, mobile app or public code.
  3. Try it from a terminal (replace YOUR_API_KEY):
Check your balance
curl https://dataspark.store/api/v1/balance \
  -H "Authorization: Bearer YOUR_API_KEY"
See the bundles and your prices
curl "https://dataspark.store/api/v1/bundles?network=mtn" \
  -H "Authorization: Bearer YOUR_API_KEY"
Buy a bundle (use a bundle id from the list above)
curl -X POST https://dataspark.store/api/v1/orders \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"bundle_id":"b-mtn-1","phone":"0244123456","client_reference":"ORDER-1001"}'

The reply to the last call looks like this. The order starts as processing:

{
  "id": "7b0f6f4c-3c1e-4c6e-8f36-0f8f3b8e2a11",
  "client_reference": "ORDER-1001",
  "status": "processing",
  "network": "mtn",
  "bundle": "MTN 1GB",
  "phone": "0244123456",
  "amount": 4.2,
  "currency": "GHS",
  "refunded": false,
  "created_at": "2026-10-03T08:15:30.000Z",
  "completed_at": null
}
Check on it later (use the id from the reply)
curl https://dataspark.store/api/v1/orders/7b0f6f4c-3c1e-4c6e-8f36-0f8f3b8e2a11 \
  -H "Authorization: Bearer YOUR_API_KEY"

Authentication

Send your key in the Authorization header on every request:

Authorization: Bearer dc_live_xxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

You can use the header X-API-Key: YOUR_API_KEY instead if your tool cannot set Authorization. Keys sent in the URL are rejected.

Treat a key like a password. Anyone who has it can spend your wallet. Use a separate key for each website or app. If a key might have leaked, revoke it in your dashboard immediately and create a new one. You can also lower each key's daily spending limit there.

This API is for servers. Browsers are deliberately not allowed to call it directly, so a key placed in website JavaScript would not work (and would be unsafe). Your website sends the purchase to your server, and your server calls us.

The basics

Networksmtn, telecel, airteltigo
Phone numbersGhana numbers in any of these forms: 0244123456, 233244123456, +233244123456. We store and return them as 0244123456.
PricesNumbers in GHS (for example 4.2). GET /bundles always shows your price.
TimesISO 8601, in UTC (for example 2026-10-03T08:15:30.000Z).
Request bodyJSON, with Content-Type: application/json, up to 4 KB.
SupportEvery response has an X-Request-Id header (errors also carry it as request_id). Quote it when you contact support.

How an order works

  1. You place the order with POST /orders. If your wallet has enough balance, we accept it, charge your wallet and reply 202 with the order. Its status is processing.
  2. We deliver it in the background. This is usually quick, but delivery time is not guaranteed, and some orders take longer.
  3. You check the status with GET /orders/{id} (or by your own reference). When it changes to completed the customer has the data.
StatusMeaningWhat to do
processingAccepted and being delivered.Keep checking. Do not place the order again: that would charge you twice.
completedDelivered.Mark your customer's order as done.
failedIt could not be delivered. refunded tells you whether the amount is back in your wallet.Tell your customer. You may place a new order with a new client_reference.
Checking status: every 10 to 30 seconds for the first few minutes, then every few minutes, is plenty. Your key allows a limited number of requests per minute (see Limits). If an order is still processing after a couple of hours, contact support with its id instead of ordering again. There is no cancel endpoint.

Prefer not to keep checking? Set up a webhook and we will tell you the moment an order completes or fails.

Webhooks

Instead of checking every order again and again, you can let us tell you. When an order is completed or failed, we send a signed message to a URL on your server, straight away.

EventWhen
order.completedThe order was delivered. data.status is completed.
order.failedThe order could not be delivered. data.status is failed; data.refunded tells you whether the amount is back in your wallet.
pingA test message, sent when you click Send test event.

There is no event while an order is processing: nothing has changed for you yet.

Set it up

  1. In your dashboard open Developer API and find Webhooks.
  2. Enter your URL and click Save. It must start with https://, use a domain name (not an IP address) and be reachable from the internet. We do not follow redirects, so give us the final URL.
  3. Copy your signing secret (it starts with whsec_). It is shown once. Store it like an API key, for example in an environment variable.
  4. Click Send test event to check that your server is reachable and your signature check works.

What we send

A POST with a JSON body. The data object is the same order object you get from GET /orders/{id}.

example body
{
  "id": "5b0c9f1e-7d52-4a1b-9a53-0d3e8c2b6f10",
  "event": "order.completed",
  "created_at": "2026-10-07T10:15:30.123Z",
  "data": {
    "id": "1b5401b8-1000-42dd-ad9c-5c161aa774ce",
    "client_reference": "ORDER-1001",
    "status": "completed",
    "network": "mtn",
    "bundle": "MTN 1GB",
    "phone": "0244123456",
    "amount": 4.4,
    "currency": "GHS",
    "refunded": false,
    "created_at": "2026-10-07T09:58:02.000Z",
    "completed_at": "2026-10-07T10:15:29.000Z"
  }
}
HeaderMeaning
X-Dataspark-Signaturet=<unix seconds>,v1=<signature>. Used to check the message is really from us (see below).
X-Dataspark-EventThe event name, for example order.completed.
X-Dataspark-DeliveryA unique id for this message. It is the same on every retry, so use it to ignore a message you have already handled.

Check the signature

Anyone can post to your URL, so always check the signature before you trust a message. The signature is an HMAC-SHA256 of t + "." + the raw request body, using your signing secret, written as lowercase hex.

  1. Read the raw body exactly as received (do not parse and re-print the JSON first).
  2. Take t and v1 from the X-Dataspark-Signature header.
  3. Compute the HMAC-SHA256 of t + "." + body with your secret and compare it to v1 with a constant-time comparison.
  4. Reject the message if t is more than 5 minutes old, so that an old message cannot be replayed.
webhook.mjs
import crypto from "node:crypto";
import express from "express";

const app = express();

// IMPORTANT: use the RAW body, exactly as we sent it. Do not parse it as JSON before checking the signature.
app.post("/dataspark-webhook", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  const parts = Object.fromEntries((req.get("X-Dataspark-Signature") || "").split(",").map((p) => p.split("=")));
  const expected = crypto
    .createHmac("sha256", process.env.DATASPARK_WEBHOOK_SECRET)   // the whsec_... secret from your dashboard
    .update(`${parts.t}.${raw}`)
    .digest("hex");

  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;          // reject old (replayed) messages
  const valid = typeof parts.v1 === "string" && parts.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  if (!fresh || !valid) return res.status(400).send("bad signature");

  const event = JSON.parse(raw);
  // event.id is the same on every retry: if you have already handled it, just answer 200.
  if (event.event === "order.completed") {
    // event.data.client_reference is YOUR reference: mark that customer's order as delivered
  } else if (event.event === "order.failed") {
    // tell your customer; event.data.refunded says whether the money is back in your wallet
  }
  res.sendStatus(200);                                                          // any 2xx = received
});

app.listen(3000);
webhook.py
import hashlib, hmac, json, os, time
from flask import Flask, request

app = Flask(__name__)

@app.post("/dataspark-webhook")
def dataspark_webhook():
    raw = request.get_data()                       # the RAW bytes, exactly as we sent them
    header = request.headers.get("X-Dataspark-Signature", "")
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)

    secret = os.environ["DATASPARK_WEBHOOK_SECRET"].encode()   # the whsec_... secret from your dashboard
    signed = parts.get("t", "").encode() + b"." + raw
    expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()

    fresh = abs(time.time() - int(parts.get("t", "0") or 0)) < 300      # reject old (replayed) messages
    if not fresh or not hmac.compare_digest(parts.get("v1", ""), expected):
        return "bad signature", 400

    event = json.loads(raw)
    # event["id"] is the same on every retry: if you have already handled it, just answer 200.
    if event["event"] == "order.completed":
        pass   # event["data"]["client_reference"] is YOUR reference: mark that order as delivered
    elif event["event"] == "order.failed":
        pass   # tell your customer; event["data"]["refunded"] says whether the money is back in your wallet
    return "", 200                                 # any 2xx = received

Replies and retries

  • Answer with any 2xx status within 10 seconds. Do your real work after you answer.
  • If we get any other answer (or none), we try again after 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours and 12 hours: 8 attempts in all, over about 22 hours.
  • If 5 messages in a row are given up on, we switch your webhook off and tell you in your dashboard. Fix your server, then click Save to switch it back on. Your orders are never affected.
  • Messages can arrive more than once, and not always in order. Use X-Dataspark-Delivery to ignore duplicates, and the data.status inside the message, not the arrival order, as the truth.
Keep a backup: a webhook can fail if your server is down. For orders that stay processing for a long time, it is still a good idea to check the status yourself with GET /orders/{id}.

Bulk orders

Need to send many orders at once? POST /v1/orders/bulk takes up to 50 orders in one request. Each order is charged and processed on its own, exactly like a single order, so one bad line never blocks the rest.

POST /v1/orders/bulk
{
  "orders": [
    { "bundle_id": "BUNDLE_ID", "phone": "0244123456", "client_reference": "ORD-1001" },
    { "bundle_id": "BUNDLE_ID", "phone": "0551234567", "client_reference": "ORD-1002" }
  ]
}

The reply is 200 with a result for every line, in the same order:

{
  "summary": { "total": 2, "accepted": 1, "replayed": 0, "rejected": 1 },
  "results": [
    { "client_reference": "ORD-1001", "status": "accepted", "order": { "id": "...", "status": "processing", ... } },
    { "client_reference": "ORD-1002", "status": "rejected", "error": { "code": "invalid_phone", "message": "..." } }
  ]
}
  • accepted: charged and on its way. replayed: we already had this client_reference; nothing was charged again. rejected: not placed and not charged; read the error.
  • Safe to retry: if your connection drops, send the same request again. Orders already placed come back as replayed.
  • Every order needs its own unique client_reference. A repeat inside one request is rejected.
  • Limits: a bulk request counts as one request per 10 orders against your per-minute limit (50 orders = 5). Your wallet balance and daily spending limit apply to each order as usual; when either runs out, the remaining lines come back rejected.
  • Track results with webhooks or GET /v1/orders?client_reference=....

IP allowlist (optional)

For extra safety you can lock a key to your own server. In your dashboard open Developer API, choose IP allowlist on the key and enter the public address (or range) your server calls us from, one per line. A stolen key is then useless from anywhere else. Leave the list empty and the key works from any address.

  • Find your address: call GET /v1/ip with your key from your server. It returns {"ip": "..."}, the address we see. Add exactly that. This call works even when your list would block you.
  • Formats: single addresses (203.0.113.5) or ranges (203.0.113.0/24), IPv4 or IPv6. Up to 10 entries. Very large ranges are refused.
  • Blocked requests get 403 ip_not_allowed and are not counted against your rate limit.
  • Not for changing addresses: if your server's address changes (home internet, some serverless hosts), do not use an allowlist, or you will lock yourself out. You can clear the list at any time in the dashboard.

Retrying safely

Networks fail. Your request can time out after we already accepted the order, and you will not know. That is why every order needs your own unique client_reference, for example your shop's order number.

  • If a request fails or times out, send exactly the same request again, with the same client_reference. If the first one went through you get the original order back (status 200, header Idempotent-Replayed: true) and you are not charged again. If it did not go through, the retry places it.
  • Or look the order up first with GET /orders?client_reference=.... A 404 means it was never placed.
  • Use a new client_reference only for a new purchase. Reusing one with a different phone or bundle returns 409 idempotency_mismatch.
  • A client_reference belongs to your account for good; it cannot be reused later for another purchase.
What happenedWhat to do
Timeout, connection error, 5xxRetry with the same client_reference (wait a few seconds between attempts).
429Wait the number of seconds in Retry-After, then retry with the same client_reference.
402 insufficient balanceNothing was charged. Top up, then retry with the same client_reference.
Other 4xxThe request is wrong. Fix it; do not retry unchanged.

Endpoint reference

Every endpoint needs your API key. Responses below are real examples.

Get your wallet balance

GET /balance

Example request

cURL
curl https://dataspark.store/api/v1/balance \
  -H "Authorization: Bearer YOUR_API_KEY"

Responses

200 Your current wallet balance.
Example response
{
  "balance": 125.5,
  "currency": "GHS"
}
401 The API key is missing, wrong or revoked.
Example response
{
  "error": {
    "code": "invalid_api_key",
    "message": "Invalid or missing API key.",
    "request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
  }
}
403 API access is switched off, or this key is not allowed to do that.
405 Wrong HTTP method.
429 Too many requests this minute.
Example response
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Slow down and retry.",
    "request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
  }
}
500 Something went wrong on our side. Retry; for orders, retry with the same `client_reference`.

List the bundles you can buy

GET /bundles

Returns every active data bundle with your price (your Custom Pricing if you have one, otherwise the standard price). Use a bundle's id as bundle_id when placing an order. size_gb is read from the bundle name and can be null.

Query / path parameters

NameInTypeRequiredDescription
networkquerymtn | telecel | airteltigonoOnly return bundles for this network.

Example request

cURL
curl "https://dataspark.store/api/v1/bundles?network=mtn" \
  -H "Authorization: Bearer YOUR_API_KEY"

Responses

200 The bundles, cheapest first.
Example response
{
  "data": [
    {
      "id": "b-mtn-1",
      "network": "mtn",
      "name": "MTN 1GB",
      "size_gb": 1,
      "validity": "30 days",
      "price": 4.2,
      "currency": "GHS"
    },
    {
      "id": "b-mtn-2",
      "network": "mtn",
      "name": "MTN 2GB",
      "size_gb": 2,
      "validity": "30 days",
      "price": 9.5,
      "currency": "GHS"
    }
  ]
}
400 The request is invalid. See `error.code`.
Example response
{
  "error": {
    "code": "invalid_phone",
    "message": "phone must be a Ghana number, e.g. 0244123456 or +233244123456.",
    "request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
  }
}
401 The API key is missing, wrong or revoked.
Example response
{
  "error": {
    "code": "invalid_api_key",
    "message": "Invalid or missing API key.",
    "request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
  }
}
403 API access is switched off, or this key is not allowed to do that.
405 Wrong HTTP method.
429 Too many requests this minute.
Example response
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Slow down and retry.",
    "request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
  }
}
500 Something went wrong on our side. Retry; for orders, retry with the same `client_reference`.

Place an order

POST /orders

Buys one bundle for one phone number. Your wallet is charged when the order is accepted.

Safe to retry. client_reference is your own unique id for this purchase (for example your shop's order number). If you send the same client_reference again with the same details, you get the original order back with status 200 and the header Idempotent-Replayed: true, and you are not charged again. If you send it again with different details, you get 409 idempotency_mismatch.

Delivery happens in the background: a new order always starts as processing.

Request body (JSON)

FieldTypeRequiredDescription
bundle_idstringyesThe `id` of a bundle from GET /bundles.
phonestringyesGhana number that receives the data: 0244123456, 233244123456 or +233244123456.
client_referencestringyesYOUR unique id for this purchase. Reusing it never charges you twice.
networkmtn | telecel | airteltigonoOptional. If you send it, it must match the bundle.

Example request

cURL
curl -X POST https://dataspark.store/api/v1/orders \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"bundle_id":"b-mtn-1","phone":"0244123456","client_reference":"ORDER-1001"}'

Responses

200 This `client_reference` was already used with the same details. The original order is returned and nothing new is charged.
202 Order accepted. It is being delivered.
Example response
{
  "id": "7b0f6f4c-3c1e-4c6e-8f36-0f8f3b8e2a11",
  "client_reference": "ORDER-1001",
  "status": "processing",
  "network": "mtn",
  "bundle": "MTN 1GB",
  "phone": "0244123456",
  "amount": 4.2,
  "currency": "GHS",
  "refunded": false,
  "created_at": "2026-10-03T08:15:30.000Z",
  "completed_at": null
}
400 The request is invalid. See `error.code`.
Example response
{
  "error": {
    "code": "invalid_phone",
    "message": "phone must be a Ghana number, e.g. 0244123456 or +233244123456.",
    "request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
  }
}
401 The API key is missing, wrong or revoked.
Example response
{
  "error": {
    "code": "invalid_api_key",
    "message": "Invalid or missing API key.",
    "request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
  }
}
402 Your wallet balance is too low. Nothing was charged. Top up and try again.
Example response
{
  "error": {
    "code": "insufficient_balance",
    "message": "Your wallet balance is too low for this order. Nothing was charged.",
    "request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
  }
}
403 API access is switched off, or this key is not allowed to do that.
404 The bundle does not exist or is not available.
Example response
{
  "error": {
    "code": "bundle_not_found",
    "message": "Bundle not found or unavailable.",
    "request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
  }
}
405 Wrong HTTP method.
409 This `client_reference` was already used with different details (another phone, amount or bundle).
Example response
{
  "error": {
    "code": "idempotency_mismatch",
    "message": "This client_reference was already used with different order details.",
    "request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
  }
}
413 The request body is too large.
422 The `network` you sent does not match the bundle.
429 Rate limit or this key's daily spending limit reached.
Example response
{
  "error": {
    "code": "daily_limit_exceeded",
    "message": "The daily spending limit for this API key has been reached.",
    "request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
  }
}
500 Something went wrong on our side. Retry; for orders, retry with the same `client_reference`.
503 The bundle is temporarily unavailable. Try again later.

Find or list your orders

GET /orders

  • With client_reference: returns that one order (the way to find out what happened after a timeout).
  • Without it: returns your latest orders, newest first (limit, default 20, maximum 50).

Query / path parameters

NameInTypeRequiredDescription
client_referencequerystringnoYour own reference for the order.
limitqueryinteger (default 20) (max 50)no

Example request

cURL
curl "https://dataspark.store/api/v1/orders?client_reference=ORDER-1001" \
  -H "Authorization: Bearer YOUR_API_KEY"

Responses

200 A single order (when `client_reference` is given) or a list.
400 The request is invalid. See `error.code`.
Example response
{
  "error": {
    "code": "invalid_phone",
    "message": "phone must be a Ghana number, e.g. 0244123456 or +233244123456.",
    "request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
  }
}
401 The API key is missing, wrong or revoked.
Example response
{
  "error": {
    "code": "invalid_api_key",
    "message": "Invalid or missing API key.",
    "request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
  }
}
403 API access is switched off, or this key is not allowed to do that.
404 No such order on your account.
Example response
{
  "error": {
    "code": "order_not_found",
    "message": "Order not found.",
    "request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
  }
}
405 Wrong HTTP method.
429 Too many requests this minute.
Example response
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Slow down and retry.",
    "request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
  }
}
500 Something went wrong on our side. Retry; for orders, retry with the same `client_reference`.

Get one order

GET /orders/{id}

Query / path parameters

NameInTypeRequiredDescription
idpathstring (uuid)yesThe order `id` we returned when you placed it.

Example request

cURL
curl https://dataspark.store/api/v1/orders/7b0f6f4c-3c1e-4c6e-8f36-0f8f3b8e2a11 \
  -H "Authorization: Bearer YOUR_API_KEY"

Responses

200 The order and its current status.
Example response
{
  "id": "7b0f6f4c-3c1e-4c6e-8f36-0f8f3b8e2a11",
  "client_reference": "ORDER-1001",
  "status": "completed",
  "network": "mtn",
  "bundle": "MTN 1GB",
  "phone": "0244123456",
  "amount": 4.2,
  "currency": "GHS",
  "refunded": false,
  "created_at": "2026-10-03T08:15:30.000Z",
  "completed_at": "2026-10-03T08:17:02.000Z"
}
401 The API key is missing, wrong or revoked.
Example response
{
  "error": {
    "code": "invalid_api_key",
    "message": "Invalid or missing API key.",
    "request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
  }
}
403 API access is switched off, or this key is not allowed to do that.
404 No such order on your account.
Example response
{
  "error": {
    "code": "order_not_found",
    "message": "Order not found.",
    "request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
  }
}
405 Wrong HTTP method.
429 Too many requests this minute.
Example response
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Slow down and retry.",
    "request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
  }
}
500 Something went wrong on our side. Retry; for orders, retry with the same `client_reference`.

Errors

Errors use a normal HTTP status and a JSON body. Branch on error.code: it will not change. The message is for people and may be reworded.

Error format
{
  "error": {
    "code": "invalid_api_key",
    "message": "Invalid or missing API key.",
    "request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
  }
}
CodeHTTPMeaning
invalid_api_key401The key is missing, wrong or revoked.
api_access_disabled403API access is switched off for your account.
insufficient_scope403This key is not allowed to do that.
ip_not_allowed403This key has an IP allowlist and your request came from an address that is not on it. The message shows the address we saw.
invalid_orders400Bulk request: orders must be a list of 1 to 50 orders.
sandbox_unavailable403Test keys are not available yet.
rate_limited429Too many requests this minute. Wait for Retry-After seconds.
daily_limit_exceeded429This key's daily spending limit is used up. Raise it in your dashboard (up to your maximum) or wait.
invalid_json400The body is not a JSON object.
payload_too_large413The body is too large.
invalid_bundle_id400bundle_id is missing or malformed.
invalid_client_reference400client_reference is missing or malformed (1-64 characters: A-Z a-z 0-9 . _ : -).
invalid_phone400phone is not a valid Ghana number.
invalid_network400network must be mtn, telecel or airteltigo.
network_mismatch422The network you sent does not match the bundle.
bundle_not_found404The bundle does not exist or is not available.
bundle_unavailable503The bundle is temporarily unavailable. Try again later.
insufficient_balance402Your wallet balance is too low. Nothing was charged.
idempotency_mismatch409This client_reference was already used with different details.
order_not_found404No such order on your account.
not_found404Unknown endpoint.
method_not_allowed405Wrong HTTP method for this endpoint.
internal_error500Something went wrong on our side. Retry; if it continues, contact support with the request_id.

Limits

  • Requests per minute: each key has a limit (shown in your dashboard, 30 by default). Go over it and you get 429 rate_limited with a Retry-After header. The headers X-RateLimit-Limit and X-RateLimit-Remaining show where you stand.
  • Daily spending limit: each key can spend up to a set amount in any 24 hours. You can change it in your dashboard between GH₵1 and the maximum we allow for that key. Over it you get 429 daily_limit_exceeded.
  • Keys: up to 5 active keys per account.
  • Wallet: orders are paid from your wallet balance. If it is too low you get 402 insufficient_balance and nothing is charged.

Going live

  1. Create a key and keep it in an environment variable on your server.
  2. Test with the smallest bundle and a phone number you control.
  3. Use your own unique order id as client_reference and add the retry logic from Retrying safely. The examples below already do this.
  4. Handle 402 (low balance), 429 (slow down) and failed orders in your own checkout.
  5. Check statuses on a timer; never place a second order just because the first one is still processing.
  6. Watch your wallet balance (GET /balance) and top up before it runs out.
  7. Set a daily spending limit on each key that suits your sales.

Complete examples

Each example lists the bundles, buys the first MTN one for a phone number, retries safely if the connection drops, and then checks the status. Pick your language; your choice is remembered.

Replace the key with your own (it is read from the DATASPARK_API_KEY environment variable), and the phone number with a number you control.

Any terminal with cURL installed.

commands
# Replace YOUR_API_KEY with your key. Works in any terminal that has cURL.

# 1. Check your balance
curl https://dataspark.store/api/v1/balance \
  -H "Authorization: Bearer YOUR_API_KEY"

# 2. List the bundles you can buy (with YOUR prices)
curl "https://dataspark.store/api/v1/bundles?network=mtn" \
  -H "Authorization: Bearer YOUR_API_KEY"

# 3. Place an order (use YOUR own unique id as client_reference)
curl -X POST https://dataspark.store/api/v1/orders \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"bundle_id":"BUNDLE_ID","phone":"0244123456","client_reference":"ORDER-1001"}'

# 4. Check the order (by the id we returned, or by your own reference)
curl https://dataspark.store/api/v1/orders/ORDER_ID \
  -H "Authorization: Bearer YOUR_API_KEY"

curl "https://dataspark.store/api/v1/orders?client_reference=ORDER-1001" \
  -H "Authorization: Bearer YOUR_API_KEY"

Node.js 18 or newer. No packages needed.

buy-bundle.mjs
// Node.js 18+  (no dependencies)
// Run:  DATASPARK_API_KEY=dc_live_... node buy-bundle.mjs
const BASE_URL = "https://dataspark.store/api/v1";
const API_KEY = process.env.DATASPARK_API_KEY;

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function api(method, path, body) {
  const res = await fetch(BASE_URL + path, {
    method,
    headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
    signal: AbortSignal.timeout(30_000),
  });
  return { status: res.status, data: await res.json() };
}

// Safe to retry: the same client_reference can never charge you twice.
async function placeOrder(order) {
  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      const result = await api("POST", "/orders", order);
      if (result.status < 500 && result.status !== 429) return result; // 202 placed, 200 already placed, 4xx fix the request
    } catch (err) {
      // timeout or network error: retry with the SAME client_reference
    }
    await sleep(2000 * attempt);
  }
  throw new Error("Could not place the order. Look it up by client_reference before trying again.");
}

const { data: list } = await api("GET", "/bundles?network=mtn");
const bundle = list.data[0];
console.log(`Bundle: ${bundle.name} at GHS ${bundle.price.toFixed(2)}`);

const { status, data: order } = await placeOrder({
  bundle_id: bundle.id,
  phone: "0244123456",
  client_reference: `ORDER-${Date.now()}`, // use YOUR own unique order id here
});
if (status !== 200 && status !== 202) {
  console.error(`Order rejected: ${order.error.message}`);
  process.exit(1);
}
console.log(`Order placed: ${order.id} (${order.status})`);

let current;
for (let i = 0; i < 6; i++) {
  current = (await api("GET", `/orders/${order.id}`)).data;
  if (current.status !== "processing") break;
  await sleep(10_000); // checking every 10 seconds is plenty
}
console.log(`Status: ${current.status}`);

A small Express server your website or app calls; only this server holds the API key.

server.mjs
// Express (Node.js 18+)      npm install express
// Your website or app calls THIS endpoint. Only this server knows the API key.
import express from "express";

const BASE_URL = "https://dataspark.store/api/v1";
const API_KEY = process.env.DATASPARK_API_KEY; // keep it in an environment variable, never in browser code

const app = express();
app.use(express.json());

const call = (method, path, body) =>
  fetch(BASE_URL + path, {
    method,
    headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
    signal: AbortSignal.timeout(30_000),
  });

// POST /buy  { "orderId": "1001", "phone": "0244123456", "bundleId": "..." }
app.post("/buy", async (req, res) => {
  const { orderId, phone, bundleId } = req.body || {};
  if (!orderId || !phone || !bundleId) {
    return res.status(400).json({ ok: false, error: "orderId, phone and bundleId are required" });
  }
  try {
    const r = await call("POST", "/orders", { bundle_id: bundleId, phone, client_reference: String(orderId) });
    const data = await r.json();
    if (r.status === 200 || r.status === 202) return res.json({ ok: true, order: data });
    return res.status(r.status >= 500 ? 502 : r.status).json({ ok: false, error: data.error });
  } catch (err) {
    // Result unknown (timeout, network). Calling /buy again with the SAME orderId is safe.
    return res.status(502).json({ ok: false, error: "Could not reach the data service. Retry with the same orderId." });
  }
});

// GET /buy/1001  ->  status of that purchase
app.get("/buy/:orderId", async (req, res) => {
  try {
    const r = await call("GET", `/orders?client_reference=${encodeURIComponent(req.params.orderId)}`);
    const data = await r.json();
    return res.status(r.status >= 500 ? 502 : r.status).json(r.ok ? { ok: true, order: data } : { ok: false, error: data.error });
  } catch (err) {
    return res.status(502).json({ ok: false, error: "Could not reach the data service." });
  }
});

app.listen(process.env.PORT || 3000, () => console.log("Listening"));

Python 3.8 or newer with the requests package.

buy_bundle.py
# Python 3.8+      pip install requests
# Run:  DATASPARK_API_KEY=dc_live_... python buy_bundle.py
import os
import time

import requests

BASE_URL = "https://dataspark.store/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['DATASPARK_API_KEY']}"}


def place_order(order, attempts=3):
    """Safe to retry: the same client_reference can never charge you twice."""
    for attempt in range(1, attempts + 1):
        try:
            r = requests.post(f"{BASE_URL}/orders", json=order, headers=HEADERS, timeout=30)
            if r.status_code < 500 and r.status_code != 429:
                return r  # 202 placed, 200 already placed, 4xx fix the request
        except requests.RequestException:
            pass  # timeout or network error: retry with the SAME client_reference
        time.sleep(2 * attempt)
    raise RuntimeError("Could not place the order. Look it up by client_reference before trying again.")


bundles = requests.get(f"{BASE_URL}/bundles", params={"network": "mtn"}, headers=HEADERS, timeout=30).json()["data"]
bundle = bundles[0]
print(f"Bundle: {bundle['name']} at GHS {bundle['price']:.2f}")

r = place_order({
    "bundle_id": bundle["id"],
    "phone": "0244123456",
    "client_reference": f"ORDER-{int(time.time())}",  # use YOUR own unique order id here
})
order = r.json()
if r.status_code not in (200, 202):
    raise SystemExit(f"Order rejected: {order['error']['message']}")
print(f"Order placed: {order['id']} ({order['status']})")

for _ in range(6):
    current = requests.get(f"{BASE_URL}/orders/{order['id']}", headers=HEADERS, timeout=30).json()
    if current["status"] != "processing":
        break
    time.sleep(10)  # checking every 10 seconds is plenty
print(f"Status: {current['status']}")

PHP 7.4 or newer with the cURL extension.

buy-bundle.php
<?php
// PHP 7.4+ with the cURL extension
// Run:  DATASPARK_API_KEY=dc_live_... php buy-bundle.php

const BASE_URL = 'https://dataspark.store/api/v1';
$apiKey = getenv('DATASPARK_API_KEY');

/** Returns [httpStatus, decodedJson]. httpStatus is 0 on a network error or timeout. */
function api(string $method, string $path, ?array $body = null): array
{
    global $apiKey;
    $ch = curl_init(BASE_URL . $path);
    $options = [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 30,
        CURLOPT_HTTPHEADER     => ["Authorization: Bearer $apiKey", 'Content-Type: application/json'],
    ];
    if ($body !== null) {
        $options[CURLOPT_POSTFIELDS] = json_encode($body);
    }
    curl_setopt_array($ch, $options);
    $raw = curl_exec($ch);
    $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($raw === false) {
        return [0, null];
    }
    return [$status, json_decode($raw, true)];
}

// Safe to retry: the same client_reference can never charge you twice.
function placeOrder(array $order): array
{
    for ($attempt = 1; $attempt <= 3; $attempt++) {
        [$status, $data] = api('POST', '/orders', $order);
        if ($status !== 0 && $status < 500 && $status !== 429) {
            return [$status, $data]; // 202 placed, 200 already placed, 4xx fix the request
        }
        sleep(2 * $attempt); // timeout or network error: retry with the SAME client_reference
    }
    throw new RuntimeException('Could not place the order. Look it up by client_reference before trying again.');
}

[, $list] = api('GET', '/bundles?network=mtn');
$bundle = $list['data'][0];
printf("Bundle: %s at GHS %.2f\n", $bundle['name'], $bundle['price']);

[$status, $order] = placeOrder([
    'bundle_id'        => $bundle['id'],
    'phone'            => '0244123456',
    'client_reference' => 'ORDER-' . time(), // use YOUR own unique order id here
]);
if ($status !== 200 && $status !== 202) {
    fwrite(STDERR, 'Order rejected: ' . $order['error']['message'] . "\n");
    exit(1);
}
echo "Order placed: {$order['id']} ({$order['status']})\n";

for ($i = 0; $i < 6; $i++) {
    [, $current] = api('GET', '/orders/' . $order['id']);
    if ($current['status'] !== 'processing') {
        break;
    }
    sleep(10); // checking every 10 seconds is plenty
}
echo "Status: {$current['status']}\n";

Example plugin: when a WooCommerce order is paid, it buys the bundle. Adapt it, and test on a staging site first.

dataspark-bundles.php
<?php
/**
 * Plugin Name: Dataspark Data Bundles for WooCommerce (example)
 * Description: When a WooCommerce order is paid, buys the matching data bundle through the Dataspark API.
 *
 * SETUP
 *  1. In wp-config.php (never in the database or the theme):
 *       define('DATASPARK_API_KEY', 'dc_live_...');
 *  2. On each WooCommerce product, add a custom field  _dataspark_bundle_id  = the bundle id from GET /bundles.
 *  3. Make sure checkout asks for the billing phone number (the number that receives the data).
 *
 * This is an EXAMPLE to adapt. Test it on a staging site with a small bundle first.
 */
defined('ABSPATH') || exit;

const DATASPARK_BASE_URL = 'https://dataspark.store/api/v1';

/** Returns [httpStatus, decodedJson]; httpStatus is 0 on a network error or timeout. */
function dataspark_request(string $method, string $path, ?array $body = null): array
{
    $args = [
        'method'  => $method,
        'timeout' => 30,
        'headers' => [
            'Authorization' => 'Bearer ' . DATASPARK_API_KEY,
            'Content-Type'  => 'application/json',
        ],
    ];
    if ($body !== null) {
        $args['body'] = wp_json_encode($body);
    }
    $response = wp_remote_request(DATASPARK_BASE_URL . $path, $args);
    if (is_wp_error($response)) {
        return [0, null];
    }
    return [(int) wp_remote_retrieve_response_code($response), json_decode(wp_remote_retrieve_body($response), true)];
}

/** Buys one bundle. Safe to run twice: the same client_reference can never charge you twice. */
function dataspark_buy_bundle(string $bundleId, string $phone, string $reference): array
{
    for ($attempt = 1; $attempt <= 3; $attempt++) {
        [$status, $data] = dataspark_request('POST', '/orders', [
            'bundle_id'        => $bundleId,
            'phone'            => $phone,
            'client_reference' => $reference,
        ]);
        if ($status !== 0 && $status < 500 && $status !== 429) {
            return [$status, $data]; // 202 placed, 200 already placed, 4xx a problem with the request
        }
        sleep(2 * $attempt);
    }
    return [0, null];
}

add_action('woocommerce_order_status_processing', function ($order_id) {
    $order = wc_get_order($order_id);
    if (!$order) {
        return;
    }
    foreach ($order->get_items() as $item_id => $item) {
        $bundleId = get_post_meta($item->get_product_id(), '_dataspark_bundle_id', true);
        if (!$bundleId) {
            continue; // not a data bundle product
        }
        if ($item->get_meta('_dataspark_order_id')) {
            continue; // already bought (the status hook can fire more than once)
        }
        $reference = "wc-{$order_id}-{$item_id}"; // unique and stable for this order line
        [$status, $data] = dataspark_buy_bundle($bundleId, $order->get_billing_phone(), $reference);

        if ($status === 200 || $status === 202) {
            $item->update_meta_data('_dataspark_order_id', $data['id']);
            $item->save();
            $order->add_order_note("Data bundle ordered (Dataspark order {$data['id']}).");
        } else {
            $why = $data['error']['message'] ?? 'No answer from the data service.';
            $order->add_order_note("Data bundle NOT ordered: {$why} Check before trying again.");
            $order->update_status('on-hold');
        }
    }
});

Ruby 2.7 or newer. Standard library only.

buy_bundle.rb
# Ruby 2.7+  (standard library only)
# Run:  DATASPARK_API_KEY=dc_live_... ruby buy_bundle.rb
require "json"
require "net/http"
require "uri"

BASE_URL = "https://dataspark.store/api/v1"
API_KEY = ENV.fetch("DATASPARK_API_KEY")

def api(method, path, body = nil)
  uri = URI(BASE_URL + path)
  request = Net::HTTP.const_get(method.capitalize).new(uri)
  request["Authorization"] = "Bearer #{API_KEY}"
  request["Content-Type"] = "application/json"
  request.body = JSON.generate(body) if body
  response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: 30) { |http| http.request(request) }
  [response.code.to_i, JSON.parse(response.body)]
end

# Safe to retry: the same client_reference can never charge you twice.
def place_order(order)
  3.times do |i|
    begin
      status, data = api("post", "/orders", order)
      return [status, data] if status < 500 && status != 429 # 202 placed, 200 already placed, 4xx fix the request
    rescue StandardError
      # timeout or network error: retry with the SAME client_reference
    end
    sleep(2 * (i + 1))
  end
  raise "Could not place the order. Look it up by client_reference before trying again."
end

_, list = api("get", "/bundles?network=mtn")
bundle = list["data"].first
puts format("Bundle: %s at GHS %.2f", bundle["name"], bundle["price"])

status, order = place_order(
  "bundle_id" => bundle["id"],
  "phone" => "0244123456",
  "client_reference" => "ORDER-#{Time.now.to_i}" # use YOUR own unique order id here
)
abort("Order rejected: #{order['error']['message']}") unless [200, 202].include?(status)
puts "Order placed: #{order['id']} (#{order['status']})"

current = nil
6.times do
  _, current = api("get", "/orders/#{order['id']}")
  break unless current["status"] == "processing"
  sleep 10 # checking every 10 seconds is plenty
end
puts "Status: #{current['status']}"

Java 17 or newer. Uses a tiny JSON helper so it needs no libraries; in a real project use Jackson or Gson.

BuyBundle.java
// Java 17+  (no dependencies; in real projects parse JSON with Jackson or Gson)
// Run:  DATASPARK_API_KEY=dc_live_... java BuyBundle.java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class BuyBundle {
    static final String BASE_URL = "https://dataspark.store/api/v1";
    static final String API_KEY = System.getenv("DATASPARK_API_KEY");
    static final HttpClient CLIENT = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build();

    record Reply(int status, String body) {}

    static Reply api(String method, String path, String jsonBody) throws Exception {
        HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE_URL + path))
                .timeout(Duration.ofSeconds(30))
                .header("Authorization", "Bearer " + API_KEY)
                .header("Content-Type", "application/json");
        b.method(method, jsonBody == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(jsonBody));
        HttpResponse<String> r = CLIENT.send(b.build(), HttpResponse.BodyHandlers.ofString());
        return new Reply(r.statusCode(), r.body());
    }

    // Tiny helper so this sample needs no libraries: reads a top-level-looking string/number field.
    static String field(String json, String name) {
        Matcher m = Pattern.compile("\"" + name + "\"\\s*:\\s*(?:\"([^\"]*)\"|([0-9.]+))").matcher(json);
        return m.find() ? (m.group(1) != null ? m.group(1) : m.group(2)) : null;
    }

    // Safe to retry: the same client_reference can never charge you twice.
    static Reply placeOrder(String json) throws Exception {
        for (int attempt = 1; attempt <= 3; attempt++) {
            try {
                Reply r = api("POST", "/orders", json);
                if (r.status() < 500 && r.status() != 429) return r; // 202 placed, 200 already placed, 4xx fix the request
            } catch (java.io.IOException e) {
                // timeout or network error: retry with the SAME client_reference
            }
            Thread.sleep(2000L * attempt);
        }
        throw new IllegalStateException("Could not place the order. Look it up by client_reference before trying again.");
    }

    public static void main(String[] args) throws Exception {
        String list = api("GET", "/bundles?network=mtn", null).body();
        String bundleId = field(list, "id");
        System.out.printf("Bundle: %s at GHS %.2f%n", field(list, "name"), Double.parseDouble(field(list, "price")));

        String reference = "ORDER-" + System.currentTimeMillis(); // use YOUR own unique order id here
        Reply order = placeOrder("{\"bundle_id\":\"" + bundleId + "\",\"phone\":\"0244123456\",\"client_reference\":\"" + reference + "\"}");
        if (order.status() != 200 && order.status() != 202) {
            System.err.println("Order rejected: " + field(order.body(), "message"));
            System.exit(1);
        }
        String orderId = field(order.body(), "id");
        System.out.println("Order placed: " + orderId + " (" + field(order.body(), "status") + ")");

        String status = null;
        for (int i = 0; i < 6; i++) {
            status = field(api("GET", "/orders/" + orderId, null).body(), "status");
            if (!"processing".equals(status)) break;
            Thread.sleep(10_000); // checking every 10 seconds is plenty
        }
        System.out.println("Status: " + status);
    }
}

.NET 6 or newer. No packages needed.

Program.cs
// C# / .NET 6+  (no packages needed)
// Run:  DATASPARK_API_KEY=dc_live_... dotnet run
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

const string BaseUrl = "https://dataspark.store/api/v1";
var apiKey = Environment.GetEnvironmentVariable("DATASPARK_API_KEY");

using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);

async Task<(int Status, JsonElement Body)> Api(HttpMethod method, string path, object? body = null)
{
    using var request = new HttpRequestMessage(method, BaseUrl + path);
    if (body != null)
        request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
    using var response = await http.SendAsync(request);
    var text = await response.Content.ReadAsStringAsync();
    return ((int)response.StatusCode, JsonDocument.Parse(text).RootElement.Clone());
}

// Safe to retry: the same client_reference can never charge you twice.
async Task<(int Status, JsonElement Body)> PlaceOrder(object order)
{
    for (var attempt = 1; attempt <= 3; attempt++)
    {
        try
        {
            var result = await Api(HttpMethod.Post, "/orders", order);
            if (result.Status < 500 && result.Status != 429) return result; // 202 placed, 200 already placed, 4xx fix the request
        }
        catch (Exception e) when (e is HttpRequestException or TaskCanceledException)
        {
            // timeout or network error: retry with the SAME client_reference
        }
        await Task.Delay(2000 * attempt);
    }
    throw new Exception("Could not place the order. Look it up by client_reference before trying again.");
}

var list = (await Api(HttpMethod.Get, "/bundles?network=mtn")).Body;
var bundle = list.GetProperty("data")[0];
Console.WriteLine($"Bundle: {bundle.GetProperty("name").GetString()} at GHS {bundle.GetProperty("price").GetDecimal():F2}");

var (status, order) = await PlaceOrder(new
{
    bundle_id = bundle.GetProperty("id").GetString(),
    phone = "0244123456",
    client_reference = $"ORDER-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}", // use YOUR own unique order id here
});
if (status != 200 && status != 202)
{
    Console.Error.WriteLine($"Order rejected: {order.GetProperty("error").GetProperty("message").GetString()}");
    return 1;
}
var orderId = order.GetProperty("id").GetString();
Console.WriteLine($"Order placed: {orderId} ({order.GetProperty("status").GetString()})");

string? current = null;
for (var i = 0; i < 6; i++)
{
    current = (await Api(HttpMethod.Get, $"/orders/{orderId}")).Body.GetProperty("status").GetString();
    if (current != "processing") break;
    await Task.Delay(10_000); // checking every 10 seconds is plenty
}
Console.WriteLine($"Status: {current}");
return 0;

Go 1.18 or newer. Standard library only.

buy_bundle.go
// Go 1.18+  (standard library only)
// Run:  DATASPARK_API_KEY=dc_live_... go run buy_bundle.go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"time"
)

const baseURL = "https://dataspark.store/api/v1"

var (
	apiKey = os.Getenv("DATASPARK_API_KEY")
	client = &http.Client{Timeout: 30 * time.Second}
)

// api returns the HTTP status and the decoded JSON body.
func api(method, path string, body any) (int, map[string]any, error) {
	var reader *bytes.Reader
	if body != nil {
		payload, _ := json.Marshal(body)
		reader = bytes.NewReader(payload)
	} else {
		reader = bytes.NewReader(nil)
	}
	req, err := http.NewRequest(method, baseURL+path, reader)
	if err != nil {
		return 0, nil, err
	}
	req.Header.Set("Authorization", "Bearer "+apiKey)
	req.Header.Set("Content-Type", "application/json")
	res, err := client.Do(req)
	if err != nil {
		return 0, nil, err
	}
	defer res.Body.Close()
	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		return res.StatusCode, nil, err
	}
	return res.StatusCode, data, nil
}

// placeOrder is safe to retry: the same client_reference can never charge you twice.
func placeOrder(order map[string]any) (int, map[string]any, error) {
	for attempt := 1; attempt <= 3; attempt++ {
		status, data, err := api("POST", "/orders", order)
		if err == nil && status < 500 && status != 429 {
			return status, data, nil // 202 placed, 200 already placed, 4xx fix the request
		}
		time.Sleep(time.Duration(2*attempt) * time.Second) // timeout or network error: retry with the SAME client_reference
	}
	return 0, nil, fmt.Errorf("could not place the order; look it up by client_reference before trying again")
}

func main() {
	_, list, err := api("GET", "/bundles?network=mtn", nil)
	if err != nil {
		panic(err)
	}
	bundle := list["data"].([]any)[0].(map[string]any)
	fmt.Printf("Bundle: %s at GHS %.2f\n", bundle["name"], bundle["price"].(float64))

	status, order, err := placeOrder(map[string]any{
		"bundle_id":        bundle["id"],
		"phone":            "0244123456",
		"client_reference": fmt.Sprintf("ORDER-%d", time.Now().Unix()), // use YOUR own unique order id here
	})
	if err != nil {
		panic(err)
	}
	if status != 200 && status != 202 {
		fmt.Println("Order rejected:", order["error"].(map[string]any)["message"])
		os.Exit(1)
	}
	fmt.Printf("Order placed: %s (%s)\n", order["id"], order["status"])

	var current map[string]any
	for i := 0; i < 6; i++ {
		_, current, _ = api("GET", "/orders/"+order["id"].(string), nil)
		if current["status"] != "processing" {
			break
		}
		time.Sleep(10 * time.Second) // checking every 10 seconds is plenty
	}
	fmt.Printf("Status: %s\n", current["status"])
}

Other language? Any HTTP library works the same way, and the OpenAPI file can generate a ready-made client for most languages.

OpenAPI file

The whole API is described in a standard OpenAPI 3 file: openapi.yaml. Import it into Postman or Insomnia to try the API, or use a generator (for example OpenAPI Generator) to create a client library in Java, C#, PHP, Python, Ruby, Go, TypeScript and many other languages.