StevLink API · v1

Sell data bundles from your own website.

One REST API for MTN, Telecel and AT bundles at your StevLink price, paid from your wallet, with a free sandbox and signed delivery webhooks.

Base URL
https://stevlink.com/api/v1
Auth
Bearer sk_live_… / sk_test_…
Format
JSON · amounts in pesewas

Quickstart

  1. 1Sign in and open Dashboard → API. Approved resellers have access straight away; other accounts send a short request and StevLink enables it.
  2. 2Create a test key (sk_test_…). It uses a virtual GH₵ 1,000 sandbox balance: nothing is charged or delivered.
  3. 3Call GET /v1/packages to see bundles at your price, then POST /v1/orders with a package id and phone number.
  4. 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. read covers balance, packages and orders. orders is needed to place orders. A key without the permission gets insufficient_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.

FieldTypeDescription
0200000001recipientThe order fails: provider_status FAILED, status refunded, sandbox balance refunded.
0200000002recipientThe order stays PLACED, so you can test waiting.
any other numberrecipientThe order is DELIVERED a moment after it is placed.

Account and balance

GET/v1/accountPermission: read
GET/v1/balancePermission: read

/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

GET/v1/packagesPermission: read

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

POST/v1/ordersPermission: orders

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.

FieldTypeDescription
Idempotency-Keyheader, required8–100 characters (letters, digits, . _ : -). Use a new UUID per order and reuse it for retries.
package_idstring, requiredAn id from GET /v1/packages.
recipientstring, requiredGhana number: 0241234567 or +233241234567.
referencestring, optionalYour 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

GET/v1/orders/{id}Permission: read

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

GET/v1/ordersPermission: read

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.

FieldTypeDescription
statusquerypending, processing, successful, failed or refunded.
provider_statusqueryWAITING, PENDING, PLACED, PROCESSING, DELIVERED, VERIFIED, UNVERIFIED, FAILED or CANCELLED.
recipientqueryA phone number, in either format.
referencequeryYour own reference.
created_after / created_beforequeryISO 8601 dates, e.g. 2026-10-01T00:00:00Z.
limitquery1–100, default 20.
cursorquerynext_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.

GET/v1/afaPermission: read

Whether AFA is open right now and what one registration costs you (price_pesewas).

POST/v1/afa/registrationsPermission: orders
FieldTypeDescription
Idempotency-Keyheader, required8–100 characters. Reusing it returns the original registration and never charges twice.
full_namestring, requiredAs it appears on the ID.
phonestring, requiredThe MTN number to register: 0241234567 or +233241234567.
id_typestring, optionalghana_card (default), voter_id, passport or drivers_license. Ghana Card registrations are processed fastest.
id_numberstring, requiredGhana Card as GHA-000000000-0.
locationstring, requiredTown and area, e.g. Madina, Accra.
date_of_birthstring, requiredYYYY-MM-DD. The person must be 18 or older.
occupationstring, requirede.g. Trader.
referencestring, optionalYour 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"
}
GET/v1/afa/registrations/{id}Permission: read

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.

GET/v1/afa/registrationsPermission: read

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.

FieldTypeDescription
WAITING / PENDINGAccepted, queued at the networkWait for an update
PLACED / PROCESSINGBeing deliveredWait for an update
DELIVEREDBundle delivereddelivered: true
VERIFIEDDelivery confirmeddelivered: true
UNVERIFIEDDelivery could not be confirmed yetWait; contact support if it stays
FAILED / CANCELLEDNot deliveredRefunded 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"
  }
}
HTTPCodeMeaning
401missing_api_keySend your API key in the Authorization header: Bearer sk_live_...
401invalid_api_keyThis API key is not valid. Check that you copied the whole key.
401api_key_revokedThis API key has been revoked. Create a new key in your StevLink dashboard.
401api_key_expiredThis API key has expired. Create or rotate a key in your StevLink dashboard.
403api_key_suspendedStevLink has suspended this API key. Contact StevLink support.
403account_suspendedThis StevLink account is suspended.
403api_access_suspendedAPI access for this account is suspended. Contact StevLink support.
403api_access_not_approvedAPI access has not been approved for this account yet.
403ip_not_allowedRequests from this IP address are not allowed for this API key.
403insufficient_scopeThis API key does not have the permission this request needs.
400invalid_requestThe request is not valid.
400invalid_jsonThe request body must be a JSON object.
400invalid_phoneEnter a valid Ghana phone number, for example 0241234567 or +233241234567.
400invalid_packagepackage_id must be the id of a package from GET /v1/packages.
400idempotency_key_requiredSend an Idempotency-Key header with every order: 8 to 100 characters, for example a UUID.
400invalid_idempotency_keyThe Idempotency-Key header must be 8 to 100 letters, digits or . _ : - characters.
400invalid_cursorThe cursor is not valid. Use the next_cursor value from the previous page.
400invalid_afa_detailsCheck the AFA details: full name, phone, ID, location, date of birth (18 or older) and occupation.
400afa_live_onlyAFA registrations need a live key. Test keys can't create them, because AFA has no sandbox.
402insufficient_balanceYour StevLink wallet balance is too low for this bundle. No order was created.
404order_not_foundNo API order with this id or reference was found for this key's mode.
404afa_not_foundNo AFA registration with this id or reference was found on this account.
404not_foundThis endpoint does not exist. See https://stevlink.com/developers.
405method_not_allowedThis HTTP method is not supported on this endpoint.
409idempotency_key_reusedThis Idempotency-Key was already used for a different order. Use a new key for a new order.
409package_unavailableThis bundle is not available right now. No order was created.
409afa_reference_usedThis reference was already used for another AFA registration. Use a new reference.
429rate_limitedToo many requests. Wait until the time in the Retry-After header, then try again.
500internal_errorSomething went wrong on our side. If you were placing an order, retry with the same Idempotency-Key to get its current state.
503ordering_unavailableData ordering is paused right now. No order was created.
503afa_unavailableAFA registration is paused right now. Nothing was charged.
503api_disabledThe StevLink API is temporarily unavailable. Try again shortly.
503service_unavailableThe 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:

FieldTypeDescription
X-RateLimit-LimitheaderRequests allowed per minute.
X-RateLimit-RemainingheaderRequests left this minute.
X-RateLimit-ResetheaderUnix time when the minute resets.
Retry-Afterheader, on 429Seconds 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.

FieldTypeDescription
order.createdeventAn order was placed through the API.
order.updatedeventstatus or provider_status changed. data.previous_attributes holds the old values.
pingeventA 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.