API v1
Lodacks reseller API
Sell mobile data from your own app or website. Top up a prepaid wallet, place orders at your reseller prices, and get webhooks as each order moves.
- Base URL
- https://lodacks.com/api/v1
- Format
- JSON over HTTPS · amounts in pesewas (GHS × 100)
Every response shown on this page is an example: ids, amounts and dates are illustrative, not real products or prices. Call the API for live data.
Getting started
- Create a Lodacks account, then apply for API access. We review every application.
- In the portal, create an API key (choose its permissions; optionally lock it to your server's IPs).
- Top up your wallet with Paystack. It's credited once the payment is confirmed.
- List products, place orders, and set a webhook URL to hear about results.
There's no test mode: every order is real and charges your wallet. Start with a small top-up.
Authentication
Send your key as a bearer token on every request. Keys look like ldk_live_xxxxxxxx_… and are shown only once, when created. Keep them on your server.
curl https://lodacks.com/api/v1/balance \ -H "Authorization: Bearer ldk_live_xxxxxxxx_your_secret_key"
Permissions per key: products:read, orders:read, orders:write, wallet:read.
Errors
Errors use HTTP status codes and a stable code you can branch on:
{
"error": {
"code": "insufficient_balance",
"message": "Your wallet balance is too low for this order. Top up in the developer portal.",
"details": { "balance": 500, "price": 1150 }
}
}
| Code | HTTP | Meaning |
|---|---|---|
| invalid_api_key | 401 | Missing, malformed or revoked key. |
| account_inactive | 403 | Your reseller account is pending, suspended or rejected. |
| ip_not_allowed | 403 | The key has an IP allow-list and this address isn't on it. |
| insufficient_scope | 403 | The key lacks the permission this endpoint needs. |
| rate_limited | 429 | Too many requests. Wait for Retry-After seconds. |
| idempotency_key_required | 400 | POST /orders needs an Idempotency-Key header. |
| idempotency_key_reused | 422 | The key was used before with different parameters. |
| request_in_progress | 409 | The first request with this key is still running. |
| invalid_parameter | 422 | A parameter is missing or malformed (see details.field). |
| invalid_phone | 422 | Not a Ghana mobile number. |
| product_unavailable | 422 | Unknown product, switched off, or not sold through the API. |
| insufficient_balance | 402 | Top up your wallet. Nothing was charged; you may retry with the same key. |
| number_check_unavailable | 503 | We couldn't check the number with our suppliers just now. Nothing was charged; try again in a few minutes. |
| number_not_verified | 422 | This number can't receive data bundles from us yet (not verified with the network's supplier). Nothing was charged; try another number. |
| ordering_paused | 503 | New orders are paused for a while. Nothing was charged; try again later. |
| duplicate_client_reference | 409 | You already used this client_reference (details.order_id has the order). |
| order_not_found | 404 | No order with that id or client_reference on your account. |
| not_found | 404 | No such endpoint. |
Rate limits
Each key may make a set number of requests a minute (60 to start), and your account a set number of orders a minute (20 to start). Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; a 429 carries Retry-After. Ask us if you need more.
Idempotency
Every POST /orders needs an Idempotency-Key header: a unique value you generate per order (a UUID is ideal). If the connection drops, send the same request with the same key: you get the original answer (with Idempotent-Replayed: true) and never a second order or charge. Reusing a key with different parameters is refused.
Answers that may change on retry (402 insufficient balance, 409 in progress, 429, 5xx) aren't stored, so you can retry them with the same key.
Products
GET /products · products:read · optional ?network=mtn
Bundles you can order, with your price. Prices are set by Lodacks and can change; the price charged is the one at the moment you order, shown on the order.
{
"data": [
{
"id": "MTN-2GB-30D",
"network": "MTN",
"network_key": "mtn",
"volume_mb": 2048,
"volume": "2GB",
"validity_days": 30,
"price": 1150,
"currency": "GHS"
}
]
}
Create an order
POST /orders · orders:write
Charges your wallet the product's price and starts delivery. Parameters: product (a product id), phone (the Ghana number to receive the data, any common format), optional client_reference (your own id, unique per account, up to 64 characters).
curl -X POST https://lodacks.com/api/v1/orders \
-H "Authorization: Bearer $LODACKS_KEY" \
-H "Idempotency-Key: 6f1c2b7e-1d4e-4a7b-9b55-1e0f5c2a9d10" \
-H "Content-Type: application/json" \
-d '{"product": "MTN-2GB-30D", "phone": "0241234567", "client_reference": "INV-1042"}'
201 Created:
{
"data": {
"id": "LDK-7Q2M9XKD",
"client_reference": "INV-1042",
"status": "pending",
"product": "MTN-2GB-30D",
"network": "MTN",
"volume_mb": 2048,
"volume": "2GB",
"validity_days": 30,
"phone": "0241234567",
"amount": 1150,
"currency": "GHS",
"refunded_amount": null,
"created_at": "2026-10-05T09:12:44+00:00",
"paid_at": "2026-10-05T09:12:44+00:00",
"delivered_at": null,
"refunded_at": null
}
}
Get an order
GET /orders/{id} · orders:read
{id} is our order id (LDK-…) or your client_reference. Returns the order object above.
List orders
GET /orders · orders:read
Newest first. Filters: status, phone. Paging: page (from 1), limit (1–100, default 25).
{ "data": [ { …order… } ], "meta": { "page": 1, "limit": 25, "total": 140, "has_more": true } }
Order statuses
- pending
- : paid from your wallet and on its way (checks, queue, sent to the network).
- processing
- : the network accepted it; waiting for confirmation.
- success
- : delivered. Final.
- failed
- : couldn't be delivered. We check it and refund your wallet; it then becomes refunded.
- refunded
- : the amount is back in your wallet (refunded_amount). Final.
An order is never retried blindly: when a network's answer is unclear we confirm it first, so you're never charged for a double delivery. That can keep an order pending for a while.
Balance and transactions
GET /balance · wallet:read
{ "data": { "balance": 48500, "currency": "GHS", "formatted": "GH₵ 485.00" } }
GET /transactions · wallet:read · ?type=deposit|order_charge|refund · page, limit
{
"data": [
{ "id": "wtx_812", "type": "order_charge", "amount": -1150, "balance_after": 48500,
"currency": "GHS", "order_id": "LDK-7Q2M9XKD", "description": "Order LDK-7Q2M9XKD",
"created_at": "2026-10-05T09:12:44+00:00" }
],
"meta": { "page": 1, "limit": 25, "total": 31, "has_more": true }
}
Webhooks
Set an https URL in the portal. We POST one event per order change:
- order.created
- order.processing
- order.success
- order.failed
- order.refunded
POST /your/webhook
Lodacks-Event: order.success
Lodacks-Delivery: evt_4k2j…
Lodacks-Signature: t=1791191564,v1=5f2c…
{ "id": "evt_4k2j…", "type": "order.success", "created_at": "2026-10-05T09:13:20+00:00",
"data": { "order": { …order object… } } }
Reply with any 2xx within 5 seconds. Otherwise we retry after 1, 5, 15 and 60 minutes, then 3, 6 and 12 hours. Each event is sent at least once and may arrive more than once or out of order: use the event id to ignore repeats and the order's status as the truth (or call GET /orders/{id}).
Verifying webhooks
v1 is the hex HMAC-SHA256 of "{t}.{raw body}" using your signing secret (whsec_…, shown once in the portal). Reject the request if it doesn't match or if t is more than 5 minutes old.
// PHP
$header = $_SERVER['HTTP_LODACKS_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $sig); // ['t' => …, 'v1' => …]
$body = file_get_contents('php://input');
$expected = hash_hmac('sha256', $sig['t'].'.'.$body, getenv('LODACKS_WEBHOOK_SECRET'));
if (! hash_equals($expected, $sig['v1'] ?? '') || abs(time() - (int) $sig['t']) > 300) {
http_response_code(400); exit;
}
// Node.js (Express: use express.raw({ type: 'application/json' }) for this route)
const crypto = require('crypto');
const { t, v1 } = Object.fromEntries(req.get('Lodacks-Signature').split(',').map(p => p.split('=')));
const expected = crypto.createHmac('sha256', process.env.LODACKS_WEBHOOK_SECRET).update(`${t}.${req.body}`).digest('hex');
const ok = v1 && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)) && Math.abs(Date.now() / 1000 - t) <= 300;
Security checklist
- Keep keys and the signing secret on your server only, never in an app, web page or repository.
- Give each key only the permissions it needs, and add an IP allow-list for production servers.
- Revoke a key in the portal the moment you think it leaked; replace the signing secret the same way.
- Always verify webhook signatures, and send an Idempotency-Key with every order.
- The portal's Logs page shows every request made with your keys.