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
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.
Keep the key on your server, for example in an environment variable. Never put it in a website, mobile app or public code.
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
Networks
mtn, telecel, airteltigo
Phone numbers
Ghana numbers in any of these forms: 0244123456, 233244123456, +233244123456. We store and return them as 0244123456.
Prices
Numbers in GHS (for example 4.2). GET /bundles always shows your price.
Times
ISO 8601, in UTC (for example 2026-10-03T08:15:30.000Z).
Request body
JSON, with Content-Type: application/json, up to 4 KB.
Support
Every response has an X-Request-Id header (errors also carry it as request_id). Quote it when you contact support.
How an order works
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.
We deliver it in the background. This is usually quick, but delivery time is not guaranteed, and some orders take longer.
You check the status with GET /orders/{id} (or by your own reference). When it changes to completed the customer has the data.
Status
Meaning
What to do
processing
Accepted and being delivered.
Keep checking. Do not place the order again: that would charge you twice.
completed
Delivered.
Mark your customer's order as done.
failed
It 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 newclient_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.
Event
When
order.completed
The order was delivered. data.status is completed.
order.failed
The order could not be delivered. data.status is failed; data.refunded tells you whether the amount is back in your wallet.
ping
A 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
In your dashboard open Developer API and find Webhooks.
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.
Copy your signing secret (it starts with whsec_). It is shown once. Store it like an API key, for example in an environment variable.
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}.
t=<unix seconds>,v1=<signature>. Used to check the message is really from us (see below).
X-Dataspark-Event
The event name, for example order.completed.
X-Dataspark-Delivery
A 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.
Read the raw body exactly as received (do not parse and re-print the JSON first).
Take t and v1 from the X-Dataspark-Signature header.
Compute the HMAC-SHA256 of t + "." + body with your secret and compare it to v1 with a constant-time comparison.
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.
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 newclient_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 happened
What to do
Timeout, connection error, 5xx
Retry with the sameclient_reference (wait a few seconds between attempts).
429
Wait the number of seconds in Retry-After, then retry with the same client_reference.
402 insufficient balance
Nothing was charged. Top up, then retry with the same client_reference.
Other 4xx
The request is wrong. Fix it; do not retry unchanged.
Endpoint reference
Every endpoint needs your API key. Responses below are real examples.
{
"error": {
"code": "invalid_api_key",
"message": "Invalid or missing API key.",
"request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
}
}
403API access is switched off, or this key is not allowed to do that.
405Wrong HTTP method.
429Too 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"
}
}
500Something 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.
{
"error": {
"code": "invalid_phone",
"message": "phone must be a Ghana number, e.g. 0244123456 or +233244123456.",
"request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
}
}
401The 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"
}
}
403API access is switched off, or this key is not allowed to do that.
405Wrong HTTP method.
429Too 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"
}
}
500Something 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)
Field
Type
Required
Description
bundle_id
string
yes
The `id` of a bundle from GET /bundles.
phone
string
yes
Ghana number that receives the data: 0244123456, 233244123456 or +233244123456.
client_reference
string
yes
YOUR unique id for this purchase. Reusing it never charges you twice.
network
mtn | telecel | airteltigo
no
Optional. If you send it, it must match the bundle.
{
"error": {
"code": "invalid_phone",
"message": "phone must be a Ghana number, e.g. 0244123456 or +233244123456.",
"request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
}
}
401The 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"
}
}
402Your 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"
}
}
403API access is switched off, or this key is not allowed to do that.
404The 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"
}
}
405Wrong HTTP method.
409This `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"
}
}
413The request body is too large.
422The `network` you sent does not match the bundle.
429Rate 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"
}
}
500Something went wrong on our side. Retry; for orders, retry with the same `client_reference`.
503The 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).
200A single order (when `client_reference` is given) or a list.
400The 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"
}
}
401The 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"
}
}
403API access is switched off, or this key is not allowed to do that.
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Slow down and retry.",
"request_id": "2f0f9b5e-6f4a-4a8e-9a66-6d1f0c1c5a77"
}
}
500Something 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"
}
}
Code
HTTP
Meaning
invalid_api_key
401
The key is missing, wrong or revoked.
api_access_disabled
403
API access is switched off for your account.
insufficient_scope
403
This key is not allowed to do that.
ip_not_allowed
403
This 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_orders
400
Bulk request: orders must be a list of 1 to 50 orders.
sandbox_unavailable
403
Test keys are not available yet.
rate_limited
429
Too many requests this minute. Wait for Retry-After seconds.
daily_limit_exceeded
429
This key's daily spending limit is used up. Raise it in your dashboard (up to your maximum) or wait.
invalid_json
400
The body is not a JSON object.
payload_too_large
413
The body is too large.
invalid_bundle_id
400
bundle_id is missing or malformed.
invalid_client_reference
400
client_reference is missing or malformed (1-64 characters: A-Z a-z 0-9 . _ : -).
invalid_phone
400
phone is not a valid Ghana number.
invalid_network
400
network must be mtn, telecel or airteltigo.
network_mismatch
422
The network you sent does not match the bundle.
bundle_not_found
404
The bundle does not exist or is not available.
bundle_unavailable
503
The bundle is temporarily unavailable. Try again later.
insufficient_balance
402
Your wallet balance is too low. Nothing was charged.
idempotency_mismatch
409
This client_reference was already used with different details.
order_not_found
404
No such order on your account.
not_found
404
Unknown endpoint.
method_not_allowed
405
Wrong HTTP method for this endpoint.
internal_error
500
Something 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
Create a key and keep it in an environment variable on your server.
Test with the smallest bundle and a phone number you control.
Use your own unique order id as client_reference and add the retry logic from Retrying safely. The examples below already do this.
Handle 402 (low balance), 429 (slow down) and failed orders in your own checkout.
Check statuses on a timer; never place a second order just because the first one is still processing.
Watch your wallet balance (GET /balance) and top up before it runs out.
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.