Want to sell airtime and data from your own app? Apply for an account, or sign in to your partner portal.
Apply nowPartner sign in

Linksvert Partner API, version 1

Sell airtime and data from your own app, through Linksvert, with one simple API.

Base address: https://linksvert.com/api/v1

You pay in advance (your balance is topped up by Linksvert), your app places orders, and Linksvert delivers the airtime or data and tells you the result.

Quick start

  1. Apply on the partner application form (partner_apply.php on the Linksvert website). When you are approved you get an email with a link to choose a password for your partner portal.
  2. In the portal, make a test key (lv_test_...). Build and test with it: it uses pretend money and never sends anything to a phone.
  3. When you are ready, pay Linksvert in advance (the portal shows where to pay) and tell us in the portal ("I have paid"). We confirm it and add the money to your live balance.
  4. We switch on live access. Then you make your own live key (lv_live_...) in the portal, and switch your app from the test key to the live key.

The partner portal (partner_portal.php) is where you see your balance, every order (with a CSV download), your keys, your webhook messages and your payment notices, and where you set your webhook address and the server addresses your keys may be used from.

Try it now (replace the key):

curl https://linksvert.com/api/v1/me -H "Authorization: Bearer lv_test_YOUR_KEY"

Authentication

Send your key with every call, in the Authorization header:

Authorization: Bearer lv_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

If your host removes that header, send it as X-API-Key: lv_live_... instead. A key in the web address is never accepted.

How answers look

Every answer is JSON, with a request id you can quote when you ask for help.

{ "success": true,  "data": { ... },  "request_id": "req_9f2c..." }
{ "success": false, "error": { "code": "invalid_phone", "message": "...", "field": "phone" }, "request_id": "req_9f2c..." }

Endpoints

GET /ping

No key needed. Checks the service is up.

GET /me

Your account, mode, balance and prices.

{ "success": true, "data": {
    "partner": { "id": 4, "name": "Acme", "status": "active" },
    "mode": "live", "currency": "NGN", "balance": 98500.0, "rate_limit_per_minute": 120,
    "pricing": { "data_markup": { "type": "percent", "value": 3.0 }, "airtime_discount_percent": 2.0 },
    "webhook_configured": true } }

GET /balance

{ "balance": 98500.0, "currency": "NGN", "mode": "live" }

GET /networks

The networks, with the smallest and largest airtime order: MTN, GLO, AIRTEL, 9MOBILE.

GET /data-plans

The data plans you can sell, with your price. Prices follow the provider and are refreshed every few minutes, so list them again before showing prices to a customer.

Optional filters: network (MTN, GLO, AIRTEL, 9MOBILE), validity (daily, weekly, monthly), category (SME, SME 2, GIFTING, AWOOF, CORPORATE).

GET /data-plans?network=MTN&validity=monthly

{ "success": true, "count": 2, "data": [
  { "plan_id": "MTN_558", "network": "MTN", "name": "1.0GB", "validity": "Monthly", "category": "SME", "price": 545.9, "currency": "NGN" },
  { "plan_id": "MTN_523", "network": "MTN", "name": "2.0GB", "validity": "Monthly", "category": "SME 2", "price": 1030.0, "currency": "NGN" } ] }

If prices cannot be confirmed (for example the provider is down for a long time) the answer is 503 catalog_unavailable. Try again shortly. Linksvert never sells from a price it cannot confirm.

POST /airtime

Send airtime.

FieldRequiredMeaning
referenceyesYour own unique id for this order (6 to 64 characters: letters, numbers and . _ : -). It makes retrying safe.
networkyesMTN, GLO, AIRTEL or 9MOBILE (Globacom and Etisalat are understood too).
phoneyesA Nigerian mobile number, in any usual form: 08031234567, +2348031234567, 0803 123 4567.
amountyesWhole naira, within the limits shown by /networks (normally 50 to 50,000).
curl https://linksvert.com/api/v1/airtime \
  -H "Authorization: Bearer lv_live_YOUR_KEY" -H "Content-Type: application/json" \
  -d '{ "reference": "ORDER-10045", "network": "mtn", "phone": "08031234567", "amount": 1000 }'

{ "success": true, "idempotent_replay": false, "data": {
  "id": "LV261011A65C7B1CE1", "reference": "ORDER-10045", "type": "airtime", "network": "MTN", "phone": "08031234567",
  "plan_id": null, "plan_name": "Airtime VTU N1000", "amount": 1000.0, "price": 980.0, "currency": "NGN",
  "status": "success", "message": "Delivered", "mode": "live",
  "created_at": "2026-10-11T11:08:14+01:00", "completed_at": "2026-10-11T11:08:15+01:00" } }

amount is the airtime the customer receives. price is what is taken from your balance (the amount, less your airtime discount).

POST /data

Send a data plan.

FieldRequiredMeaning
referenceyesYour unique id for the order.
networkyesThe plan's network.
phoneyesThe number to receive the data.
plan_idyesA plan_id from /data-plans.
expected_pricenoThe price you showed your customer. If our price has changed since, the order is refused with 409 price_changed (and not charged), so you never charge a customer one price and pay another.

You cannot choose the price: it is always worked out by Linksvert.

GET /transactions/{id}

One order, by Linksvert's id (LV...) or by your reference. Use this to find out how an order ended.

GET /transactions

Your orders, newest first. Filters: status (pending, success, failed), type (airtime, data), from and to (dates like 2026-10-01). Pages: limit (1 to 100, default 25) and cursor.

GET /transactions?limit=2
{ "success": true, "count": 2, "has_more": true, "next_cursor": "1204", "data": [ ... ] }

GET /transactions?limit=2&cursor=1204      <- the next page

POST /webhooks/test

Sends a signed test message to your webhook address right now, so you can check your endpoint and your signature code.

Order status: the important part

Every order ends as success or failed. For a moment (or, rarely, a little longer) it can be pending.

statusHTTPWhat it meansWhat you do
success201Delivered.Give your customer their airtime or data.
pending202Linksvert is still waiting for the network or provider to confirm. You have been charged and nothing is refunded yet.Do not retry with a new reference. Wait for the webhook, or ask GET /transactions/{reference} every minute or so.
failed200It was not delivered, and your balance has been refunded in full.Tell your customer. You may try again with a new reference.

Why this matters: when a network does not answer, Linksvert cannot know whether the airtime went through. Refunding on a guess could give away airtime for free, so the order stays pending until the real answer is known. Pending orders are checked again automatically, and settled by Linksvert if they stay stuck.

Retrying safely (idempotency)

Your reference makes every order safe to repeat. If a call times out and you do not know what happened, send exactly the same request again, with the same reference:

Set your own request timeout to 45 seconds or more: Linksvert may wait up to 30 seconds for the network.

Webhooks

Instead of asking, you can be told. Give Linksvert an HTTPS address and a secret is made for you. When an order finishes, Linksvert sends a POST with a JSON body to your address:

{ "id": "evt_8f31a2c4d90b17e6", "event": "transaction.success", "created": "2026-10-11T11:08:15+01:00",
  "data": { "id": "LV261011A65C7B1CE1", "reference": "ORDER-10045", "status": "success", ... } }

Events: transaction.success, transaction.failed, and ping (a test).

Headers:

Check every message. The signature is an HMAC-SHA256, using your webhook secret, of the text <t>.<raw body>. Compare it in constant time, and reject messages older than 5 minutes.

PHP:

$body   = file_get_contents('php://input');                  // the raw body, exactly as received
$header = $_SERVER['HTTP_X_LINKSVERT_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $p);               // gives $p['t'] and $p['v1']
$expected = hash_hmac('sha256', ($p['t'] ?? '') . '.' . $body, $WEBHOOK_SECRET);
if (!hash_equals($expected, $p['v1'] ?? '') || abs(time() - (int)($p['t'] ?? 0)) > 300) { http_response_code(400); exit; }
// the message is genuine: act on it, then answer quickly
http_response_code(200);

Node.js:

const crypto = require('crypto');
function valid(rawBody, header, secret) {
  const p = Object.fromEntries(header.split(',').map(x => x.split('=')));
  const expected = crypto.createHmac('sha256', secret).update(p.t + '.' + rawBody).digest('hex');
  return p.v1 && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(p.v1)) && Math.abs(Date.now()/1000 - p.t) < 300;
}

Python:

import hmac, hashlib, time
def valid(raw_body: bytes, header: str, secret: str) -> bool:
    p = dict(x.split('=', 1) for x in header.split(','))
    expected = hmac.new(secret.encode(), p['t'].encode() + b'.' + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, p.get('v1', '')) and abs(time.time() - int(p['t'])) < 300

Your side:

Test mode

Test keys behave like live keys, but with pretend money (a test balance of N1,000,000) and nothing is sent to any phone. Choose the result by the last four digits of the phone number:

Phone ends withResult
anything elsesuccess at once
0000failed at once, and refunded
1111pending, then success after 30 seconds
2222pending, then failed (refunded) after 30 seconds

Webhooks work in test mode too. Test orders are never mixed with live orders, and the same reference can be used once in each.

Prices

Errors

HTTPerror.codeMeaning
400invalid_jsonThe body is not valid JSON.
401missing_api_key / invalid_api_keyNo key, or the key is wrong or revoked.
402insufficient_balanceYour balance is too low. The answer includes balance and required. Nothing was recorded.
403account_suspendedYour account is suspended. Contact Linksvert.
403ip_not_allowedThis server's address is not on your allowed list.
403https_requiredA live key was used without HTTPS.
404not_foundNo such address, or no such order.
405method_not_allowedUse the other method (GET or POST) for this address.
409reference_conflictThat reference was already used for a different order.
409price_changedThe price is not what you expected. The answer includes the new price.
422invalid_request, invalid_phone, invalid_network, invalid_amount, invalid_plan, plan_network_mismatchSomething in your request is wrong. field names it.
422no_webhookYou asked for a webhook test but no webhook address is set.
429rate_limitedToo many calls. Wait the number of seconds in Retry-After.
500server_errorThe order could not be recorded. Nothing was charged. Try again with the same reference.
503catalog_unavailablePlan prices cannot be confirmed right now. Try again shortly.
503api_disabled / service_unavailableThe service is paused or temporarily down. Try again shortly.

Limits

Before you go live

Changes

One-click connect (for apps run by the Linksvert owner)

An app can be connected without copying keys. The app sends the administrator to connect.php (with its name, website, return address, a state and the SHA-256 challenge of a secret verifier it keeps). The administrator approves it with their password. Linksvert returns to the app with a one-time code (5 minutes, single use). The app then calls, server to server:

POST /api/v1/connect/exchange with {"code": "...", "verifier": "..."} and receives test_key, webhook_secret, webhook_url (and live_key if live access is already on). POST /api/v1/connect/live signed with the test key returns the live_key once live access has been switched on.

A wrong verifier burns the code. Keys are never put in a web address. Connecting again replaces the keys made by a previous connection.