Quickstart
- 1Sign in and open Dashboard → API. Approved resellers have access straight away; other accounts send a short request and StevLink enables it.
- 2Create a test key (
sk_test_…). It uses a virtual GH₵ 1,000 sandbox balance: nothing is charged or delivered. - 3Call
GET /v1/packagesto see bundles at your price, thenPOST /v1/orderswith a package id and phone number. - 4Add a webhook endpoint to hear when orders are delivered, then switch to a live key. Keep keys on your server only.
curl https://stevlink.com/api/v1/balance \
-H "Authorization: Bearer $STEVLINK_API_KEY"Authentication
Send your key in the Authorization header of every request: Authorization: Bearer sk_live_…. Requests without a valid key get 401.
- Live and test keys.
sk_live_keys spend your wallet and deliver real bundles.sk_test_keys use the sandbox. - Permissions.
readcovers balance, packages and orders.ordersis needed to place orders. A key without the permission getsinsufficient_scope. - IP allowlist. Optionally limit a key to your server's IP addresses or ranges. Other IPs get
ip_not_allowed. - Shown once. StevLink stores only a fingerprint of each key. If you lose a key, rotate it.
- Rotation. Rotating gives you a new key with the same settings. Choose whether the old key stops now, in 1 hour or in 24 hours.
Never put an API key in browser JavaScript, a mobile app or a public repository. Call StevLink from your server and keep the key in an environment variable.
Sandbox (test mode)
Test keys behave like live keys (same validation, prices, errors, idempotency and webhooks) but never contact the network or touch your wallet. Each account has a GH₵ 1,000 sandbox balance you can reset from the dashboard. Test orders have references starting SLT- and livemode: false.
| Field | Type | Description |
|---|---|---|
| 0200000001 | recipient | The order fails: provider_status FAILED, status refunded, sandbox balance refunded. |
| 0200000002 | recipient | The order stays PLACED, so you can test waiting. |
| any other number | recipient | The order is DELIVERED a moment after it is placed. |
Account and balance
/account returns your name, pricing tier and balance. /balance returns just the balance, in pesewas (GH₵ 1 = 100 pesewas).
curl https://stevlink.com/api/v1/balance \
-H "Authorization: Bearer $STEVLINK_API_KEY"{
"object": "balance",
"available_pesewas": 125050,
"currency": "GHS",
"livemode": true,
"request_id": "req_2c9a7f0b5e1d4a6c8b3e9f10"
}Packages
Bundles you can order right now, at your price: approved resellers see reseller prices. Out-of-stock bundles are left out. Filter with ?network=mtn (mtn, mtn-express, telecel, at).
curl https://stevlink.com/api/v1/packages?network=mtn \
-H "Authorization: Bearer $STEVLINK_API_KEY"{
"object": "list",
"data": [
{
"object": "package",
"id": "0f6f1f2e-1111-4222-8333-444455556666",
"name": "1 GB",
"network": "MTN",
"network_code": "mtn",
"data_mb": 1000,
"validity_days": 90,
"price_pesewas": 450,
"currency": "GHS"
}
],
"networks": [
{
"code": "mtn",
"name": "MTN"
}
],
"livemode": true,
"request_id": "req_…"
}Create an order
Buys one bundle. The price is taken from your wallet straight away and the order is sent to the network. The response is 201 with the order; a repeat of the same request (same Idempotency-Key) returns 200 with Idempotent-Replayed: true and the original order.
| Field | Type | Description |
|---|---|---|
| Idempotency-Key | header, required | 8–100 characters (letters, digits, . _ : -). Use a new UUID per order and reuse it for retries. |
| package_id | string, required | An id from GET /v1/packages. |
| recipient | string, required | Ghana number: 0241234567 or +233241234567. |
| reference | string, optional | Your own reference, up to 64 characters. Returned as client_reference and usable as a filter. |
curl https://stevlink.com/api/v1/orders \
-H "Authorization: Bearer $STEVLINK_API_KEY" \
-H "Idempotency-Key: 0b7c6a52-6f0e-4d0f-9d44-1c1e2c0f7a91" \
-H "Content-Type: application/json" \
-d '{"package_id":"0f6f1f2e-1111-4222-8333-444455556666","recipient":"0241234567","reference":"INV-1001"}'{
"object": "order",
"id": "7d0f8f4e-2b1a-4c55-9a51-3f7a1c2d9b10",
"reference": "SL-5d8e2c51-0c3e-4a4f-9e07-8b6f0b6a1e22",
"client_reference": "INV-1001",
"livemode": true,
"status": "processing",
"provider_status": "PLACED",
"delivered": false,
"refunded": false,
"recipient": "+233241234567",
"amount_pesewas": 450,
"currency": "GHS",
"package": {
"id": "0f6f1f2e-1111-4222-8333-444455556666",
"name": "1 GB",
"network": "MTN",
"network_code": "mtn",
"data_mb": 1000,
"validity_days": 90
},
"provider_status_at": "2026-10-23T09:15:02.118Z",
"created_at": "2026-10-23T09:15:00.522Z",
"updated_at": "2026-10-23T09:15:02.118Z",
"request_id": "req_6f1c0a9e2b7d4c3a8e5f1d20"
}If the network provider refuses an order outright, StevLink refunds your wallet automatically and the order comes back with status: "refunded".
Get an order
Fetch one order by its id or StevLink reference. Live keys see live orders; test keys see sandbox orders.
curl https://stevlink.com/api/v1/orders/SL-5d8e2c51-0c3e-4a4f-9e07-8b6f0b6a1e22 \
-H "Authorization: Bearer $STEVLINK_API_KEY"List orders
Orders placed through the API, newest first. When has_more is true, pass next_cursor as cursor to get the next page. Cursors stay correct while new orders arrive.
| Field | Type | Description |
|---|---|---|
| status | query | pending, processing, successful, failed or refunded. |
| provider_status | query | WAITING, PENDING, PLACED, PROCESSING, DELIVERED, VERIFIED, UNVERIFIED, FAILED or CANCELLED. |
| recipient | query | A phone number, in either format. |
| reference | query | Your own reference. |
| created_after / created_before | query | ISO 8601 dates, e.g. 2026-10-01T00:00:00Z. |
| limit | query | 1–100, default 20. |
| cursor | query | next_cursor from the previous page. |
curl https://stevlink.com/api/v1/orders?provider_status=DELIVERED&limit=50 \
-H "Authorization: Bearer $STEVLINK_API_KEY"{
"object": "list",
"data": [
"…orders…"
],
"has_more": true,
"next_cursor": "WyIyMDI2LTEwLTIzVDA5OjE1OjAwLjUyMloiLCI3ZDBm…",
"request_id": "req_…"
}AFA registration
Register MTN numbers for AFA for your own customers. Each registration is paid from your wallet at your AFA price; approved resellers pay the reseller AFA price. Charge your customers whatever you like on your side. AFA needs a live key: test keys can't create registrations.
Whether AFA is open right now and what one registration costs you (price_pesewas).
| Field | Type | Description |
|---|---|---|
| Idempotency-Key | header, required | 8–100 characters. Reusing it returns the original registration and never charges twice. |
| full_name | string, required | As it appears on the ID. |
| phone | string, required | The MTN number to register: 0241234567 or +233241234567. |
| id_type | string, optional | ghana_card (default), voter_id, passport or drivers_license. Ghana Card registrations are processed fastest. |
| id_number | string, required | Ghana Card as GHA-000000000-0. |
| location | string, required | Town and area, e.g. Madina, Accra. |
| date_of_birth | string, required | YYYY-MM-DD. The person must be 18 or older. |
| occupation | string, required | e.g. Trader. |
| reference | string, optional | Your own reference (unique per registration), returned as client_reference and usable to fetch it. |
curl https://stevlink.com/api/v1/afa/registrations \
-H "Authorization: Bearer $STEVLINK_API_KEY" \
-H "Idempotency-Key: 6a52f0e4-0b7c-4d0f-9d44-1c1e2c0f7a91" \
-H "Content-Type: application/json" \
-d '{"full_name":"Esi Boateng","phone":"0241234567","id_type":"ghana_card","id_number":"GHA-123456789-0","location":"Madina, Accra","date_of_birth":"1994-05-12","occupation":"Trader","reference":"CUST-2201"}'{
"object": "afa_registration",
"id": "3c2b1a90-5d4e-4f6a-8b7c-1d2e3f4a5b6c",
"reference": "AFA-3F9C21D07A",
"client_reference": "CUST-2201",
"status": "pending",
"status_detail": null,
"full_name": "Esi Boateng",
"phone": "+233241234567",
"id_type": "ghana_card",
"id_number": "GHA-123456789-0",
"location": "Madina, Accra",
"date_of_birth": "1994-05-12",
"occupation": "Trader",
"amount_pesewas": 1200,
"amount": "12.00",
"currency": "GHS",
"note": null,
"refunded": false,
"created_at": "2026-10-27T09:00:00.000Z",
"updated_at": "2026-10-27T09:00:00.000Z",
"request_id": "req_2a7d4c3a8e5f1d206f1c0a9e"
}By id, StevLink reference or your own reference. status is pending, registering, registered or rejected; status_detail is the latest update from the network's AFA service, word for word. A rejected registration is refunded to your wallet (refunded: true) with the reason in note.
Newest first. Filter with status; page with limit (1–100) and offset. The response includes total.
Order statuses
Every order has two statuses. provider_status is the network provider's own status, passed on exactly as reported (or null until it reports). status is StevLink's record of the money: pending, processing, successful, failed or refunded.
Only treat an order as delivered when delivered is true, which happens only when provider_status is DELIVERED or VERIFIED. Never decide delivery from status alone.
| Field | Type | Description |
|---|---|---|
| WAITING / PENDING | Accepted, queued at the network | Wait for an update |
| PLACED / PROCESSING | Being delivered | Wait for an update |
| DELIVERED | Bundle delivered | delivered: true |
| VERIFIED | Delivery confirmed | delivered: true |
| UNVERIFIED | Delivery could not be confirmed yet | Wait; contact support if it stays |
| FAILED / CANCELLED | Not delivered | Refunded to your wallet: status becomes refunded |
Idempotency
Networks drop connections. If you do not get a response to POST /v1/orders, send exactly the same request again with the same Idempotency-Key. You get the original order back and are never charged twice.
- Create the key when you create the order in your own system and store it with the order.
- Reusing a key with a different package, recipient or reference returns
409 idempotency_key_reused. - Requests that fail validation (for example
insufficient_balance) create nothing, so the same key can be used again once the problem is fixed.
Errors
Errors always have the same shape and a stable code to branch on. The message is written for people and may change. Quote the request_id (also in the X-Request-Id header) when you contact support.
{
"error": {
"code": "insufficient_balance",
"message": "Your StevLink wallet balance is too low for this bundle. No order was created.",
"request_id": "req_9d2e4b1a7c3f4e8a9b0c6d15"
}
}| HTTP | Code | Meaning |
|---|---|---|
| 401 | missing_api_key | Send your API key in the Authorization header: Bearer sk_live_... |
| 401 | invalid_api_key | This API key is not valid. Check that you copied the whole key. |
| 401 | api_key_revoked | This API key has been revoked. Create a new key in your StevLink dashboard. |
| 401 | api_key_expired | This API key has expired. Create or rotate a key in your StevLink dashboard. |
| 403 | api_key_suspended | StevLink has suspended this API key. Contact StevLink support. |
| 403 | account_suspended | This StevLink account is suspended. |
| 403 | api_access_suspended | API access for this account is suspended. Contact StevLink support. |
| 403 | api_access_not_approved | API access has not been approved for this account yet. |
| 403 | ip_not_allowed | Requests from this IP address are not allowed for this API key. |
| 403 | insufficient_scope | This API key does not have the permission this request needs. |
| 400 | invalid_request | The request is not valid. |
| 400 | invalid_json | The request body must be a JSON object. |
| 400 | invalid_phone | Enter a valid Ghana phone number, for example 0241234567 or +233241234567. |
| 400 | invalid_package | package_id must be the id of a package from GET /v1/packages. |
| 400 | idempotency_key_required | Send an Idempotency-Key header with every order: 8 to 100 characters, for example a UUID. |
| 400 | invalid_idempotency_key | The Idempotency-Key header must be 8 to 100 letters, digits or . _ : - characters. |
| 400 | invalid_cursor | The cursor is not valid. Use the next_cursor value from the previous page. |
| 400 | invalid_afa_details | Check the AFA details: full name, phone, ID, location, date of birth (18 or older) and occupation. |
| 400 | afa_live_only | AFA registrations need a live key. Test keys can't create them, because AFA has no sandbox. |
| 402 | insufficient_balance | Your StevLink wallet balance is too low for this bundle. No order was created. |
| 404 | order_not_found | No API order with this id or reference was found for this key's mode. |
| 404 | afa_not_found | No AFA registration with this id or reference was found on this account. |
| 404 | not_found | This endpoint does not exist. See https://stevlink.com/developers. |
| 405 | method_not_allowed | This HTTP method is not supported on this endpoint. |
| 409 | idempotency_key_reused | This Idempotency-Key was already used for a different order. Use a new key for a new order. |
| 409 | package_unavailable | This bundle is not available right now. No order was created. |
| 409 | afa_reference_used | This reference was already used for another AFA registration. Use a new reference. |
| 429 | rate_limited | Too many requests. Wait until the time in the Retry-After header, then try again. |
| 500 | internal_error | Something went wrong on our side. If you were placing an order, retry with the same Idempotency-Key to get its current state. |
| 503 | ordering_unavailable | Data ordering is paused right now. No order was created. |
| 503 | afa_unavailable | AFA registration is paused right now. Nothing was charged. |
| 503 | api_disabled | The StevLink API is temporarily unavailable. Try again shortly. |
| 503 | service_unavailable | The StevLink API is temporarily unavailable. Try again shortly. |
Rate limits
Each key may make 60 requests a minute by default (StevLink can raise this for busy integrations). Every response tells you where you stand:
| Field | Type | Description |
|---|---|---|
| X-RateLimit-Limit | header | Requests allowed per minute. |
| X-RateLimit-Remaining | header | Requests left this minute. |
| X-RateLimit-Reset | header | Unix time when the minute resets. |
| Retry-After | header, on 429 | Seconds to wait before trying again. |
Over the limit you get 429 rate_limited. Wait for Retry-After seconds, then retry; for orders, retry with the same Idempotency-Key.
Webhooks
Add an HTTPS endpoint in Dashboard → API (one for live, one for test). StevLink POSTs an event when an API order is created and every time its status or provider_status changes. Use Send test event to check your endpoint.
| Field | Type | Description |
|---|---|---|
| order.created | event | An order was placed through the API. |
| order.updated | event | status or provider_status changed. data.previous_attributes holds the old values. |
| ping | event | A test event from the dashboard. |
{
"id": "evt_3b1f0c9d2e8a4f7b9c6d1e2f3a4b5c6d",
"object": "event",
"type": "order.updated",
"api_version": "2026-10-23",
"livemode": true,
"created_at": "2026-10-23T09:16:41.007Z",
"data": {
"object": {
"object": "order",
"id": "7d0f8f4e-2b1a-4c55-9a51-3f7a1c2d9b10",
"reference": "SL-5d8e2c51-0c3e-4a4f-9e07-8b6f0b6a1e22",
"client_reference": "INV-1001",
"livemode": true,
"status": "successful",
"provider_status": "DELIVERED",
"delivered": true,
"refunded": false,
"recipient": "+233241234567",
"amount_pesewas": 450,
"currency": "GHS",
"package": {
"id": "0f6f1f2e-1111-4222-8333-444455556666",
"name": "1 GB",
"network": "MTN",
"network_code": "mtn",
"data_mb": 1000,
"validity_days": 90
},
"provider_status_at": "2026-10-23T09:15:02.118Z",
"created_at": "2026-10-23T09:15:00.522Z",
"updated_at": "2026-10-23T09:15:02.118Z"
},
"previous_attributes": {
"status": "processing",
"provider_status": "PLACED"
}
}
}Verify every delivery
Each request carries StevLink-Signature: t=<unix time>,v1=<signature>. The signature is an HMAC-SHA256 of <t>.<raw body> with your endpoint's signing secret (whsec_…). Reject requests whose signature does not match or whose timestamp is more than 5 minutes old. StevLink-Event-Id, StevLink-Event-Type and StevLink-Delivery-Attempt are sent too.
import crypto from "node:crypto";
import express from "express";
const app = express();
const secret = process.env.STEVLINK_WEBHOOK_SECRET; // whsec_...
// Use the RAW body: re-serialised JSON will not match the signature.
app.post("/stevlink/webhook", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("StevLink-Signature") ?? "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const timestamp = Number(parts.t);
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${req.body}`).digest("hex");
const valid = parts.v1 && parts.v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
if (!valid || Math.abs(Date.now() / 1000 - timestamp) > 300) return res.sendStatus(400);
const event = JSON.parse(req.body);
// Ignore duplicates: store event.id and skip ids you have seen.
if (event.type === "order.updated" && event.data.object.delivered) {
// mark your order as delivered
}
res.sendStatus(200); // answer quickly; do slow work afterwards
});Retries
Answer with any 2xx within 10 seconds. Otherwise StevLink retries after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours (8 attempts in total). Redirects are not followed. Events can arrive more than once or out of order: use the event id to skip duplicates and the order's updated_at to ignore older updates. The dashboard shows every delivery and lets you retry a failed one.
Changelog
2026-10-23 · v1
First public release
Account, balance, packages and orders endpoints; live and test keys with permissions, IP allowlists and rotation; idempotent ordering; cursor pagination; signed webhooks with retries; per-key rate limits; OpenAPI 3.1 spec.
Changes inside v1 only add fields, endpoints or error codes, so build your integration to ignore fields it does not know. Anything that would break an integration will come as v2, announced in advance.