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
- Apply on the partner application form (
partner_apply.phpon the Linksvert website). When you are approved you get an email with a link to choose a password for your partner portal. - 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. - 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.
- 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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxIf your host removes that header, send it as X-API-Key: lv_live_... instead. A key in the web address is never accepted.
- Keep keys on your server. Never put a key in a mobile app, a web page or public code.
- Live keys only work over HTTPS.
- If a key is lost or leaked, ask Linksvert to revoke it and make a new one. A revoked key stops working at once.
- You can limit your keys to your server's IP address in the Settings page of your partner portal. It is a strong protection: a stolen key is then useless anywhere else. (Not sure of your server's address? Ask your developer or hosting company for its "outgoing IP address".)
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..." }- Money is in naira, as numbers (
980.0). Time is ISO 8601 in Lagos time (2026-10-11T11:08:14+01:00). - Test and live are completely separate: separate balances, separate orders.
- Always read the
statusfield of an order, not only the HTTP status (see "Order status" below).
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.
| Field | Required | Meaning |
|---|---|---|
reference | yes | Your own unique id for this order (6 to 64 characters: letters, numbers and . _ : -). It makes retrying safe. |
network | yes | MTN, GLO, AIRTEL or 9MOBILE (Globacom and Etisalat are understood too). |
phone | yes | A Nigerian mobile number, in any usual form: 08031234567, +2348031234567, 0803 123 4567. |
amount | yes | Whole 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.
| Field | Required | Meaning |
|---|---|---|
reference | yes | Your unique id for the order. |
network | yes | The plan's network. |
phone | yes | The number to receive the data. |
plan_id | yes | A plan_id from /data-plans. |
expected_price | no | The 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 pagePOST /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.
status | HTTP | What it means | What you do |
|---|---|---|---|
success | 201 | Delivered. | Give your customer their airtime or data. |
pending | 202 | Linksvert 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. |
failed | 200 | It 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:
- If the first request reached us, you get the same order back (
"idempotent_replay": true, HTTP 200). You are not charged twice. - If it did not reach us, it is placed now.
- If you send the same
referencewith a different phone, amount or plan, you get409 reference_conflict. - A
failedorder keeps its reference. To try again, use a new reference. - If your balance was too low (
402), nothing is recorded, so the same reference can be used again after you top up.
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:
X-Linksvert-Signature: t=1760177295,v1=9d5c...the signature (below)X-Linksvert-Event: transaction.successX-Linksvert-Delivery: 1187the delivery number
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'])) < 300Your side:
- Answer with any 2xx quickly (within 8 seconds). Do your slow work afterwards.
- If you do not, Linksvert tries again after 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours, 12 hours and 24 hours, then stops. You can always ask
GET /transactions/{reference}. - The same message can arrive more than once. Use
data.id(or the eventid) to ignore repeats. - Webhooks can arrive out of order. The order's
statusin the message is final:successandfailednever change. - Your webhook address must be public. Addresses pointing at private or internal networks are refused.
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 with | Result |
|---|---|
| anything else | success at once |
0000 | failed at once, and refunded |
1111 | pending, then success after 30 seconds |
2222 | pending, 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
- Data: the provider's current price for the plan, plus your markup (a percentage, or a fixed amount, agreed with Linksvert).
- Airtime: the amount you send, less your airtime discount (your commission).
- Your prices are shown in
GET /me. Plan prices come fromGET /data-plans.
Errors
| HTTP | error.code | Meaning |
|---|---|---|
| 400 | invalid_json | The body is not valid JSON. |
| 401 | missing_api_key / invalid_api_key | No key, or the key is wrong or revoked. |
| 402 | insufficient_balance | Your balance is too low. The answer includes balance and required. Nothing was recorded. |
| 403 | account_suspended | Your account is suspended. Contact Linksvert. |
| 403 | ip_not_allowed | This server's address is not on your allowed list. |
| 403 | https_required | A live key was used without HTTPS. |
| 404 | not_found | No such address, or no such order. |
| 405 | method_not_allowed | Use the other method (GET or POST) for this address. |
| 409 | reference_conflict | That reference was already used for a different order. |
| 409 | price_changed | The price is not what you expected. The answer includes the new price. |
| 422 | invalid_request, invalid_phone, invalid_network, invalid_amount, invalid_plan, plan_network_mismatch | Something in your request is wrong. field names it. |
| 422 | no_webhook | You asked for a webhook test but no webhook address is set. |
| 429 | rate_limited | Too many calls. Wait the number of seconds in Retry-After. |
| 500 | server_error | The order could not be recorded. Nothing was charged. Try again with the same reference. |
| 503 | catalog_unavailable | Plan prices cannot be confirmed right now. Try again shortly. |
| 503 | api_disabled / service_unavailable | The service is paused or temporarily down. Try again shortly. |
Limits
- Each key can make 120 calls a minute by default (Linksvert can change it). Every answer shows
X-RateLimit-LimitandX-RateLimit-Remaining. - Airtime orders are limited to the range shown by
/networks.
Before you go live
- ☐Your app uses the live key, kept only on your server, and never logged.
- ☐Every order sends a unique
referencethat you saved before calling us. - ☐You handle all three results:
success,failedandpending(and never treatpendingas a failure). - ☐Your request timeout is 45 seconds or more, and you retry timeouts with the same reference.
- ☐You verify the webhook signature, answer 2xx quickly, and ignore repeated messages.
- ☐You show your customer the price from
/data-plansand send it asexpected_price. - ☐You watch your balance (
GET /balance) and top up before it runs out. - ☐You limited your keys to your server's IP address in your portal Settings.
Changes
- v1 First release: airtime, data, plans, balance, orders, signed webhooks, test mode.
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.