Merchant API Reference
VendStack calls your HTTPS endpoints to list products and complete purchases. Everything is POST with a JSON body, and every request is signed so you can confirm it came from us.
Don't want to write this by hand? A starter kit — one drop-in file for Laravel, PHP, Node.js or ASP.NET Core — implements every endpoint below, verifies the signature, and makes
/vendidempotent. You supply only your wallet and vending logic.
You configure this once, on the dashboard's Merchant API card: a single base URL and a signing secret. Every channel — WhatsApp, USSD and any we add later — uses this same API, so you integrate once and turn channels on as you go. We call paths under your base URL, e.g. base https://api.yourbank.com/vendo → https://api.yourbank.com/vendo/vend.
You implement only what your products need
| Product | /billers |
/packages |
/validate |
/vend |
|---|---|---|---|---|
| Airtime | – | – | – | ✅ |
| Data | – | ✅ | – | ✅ |
| Cable | ✅ | ✅ | ✅ | ✅ |
| Power | ✅ | – | ✅ | ✅ |
| Betting | ✅ | – | ✅ | ✅ |
| Internet (ISPs) | – | ✅ | (optional) | ✅ |
Are you an ISP? Your customers don't hold a wallet with you, so there is no PIN and nothing to debit: they pay for each plan through your own payment gateway, and we call
/vendonly once that payment is confirmed. See Internet plans (ISPs) — it is a much smaller integration than the table above.
So airtime-only merchants implement one endpoint (/vend); a full-service merchant implements four. A few more are optional: /status (reconcile a purchase after a timeout — recommended for reliability), /balance (WhatsApp shows customers their wallet balance before they pay), /accounts (powers the Fund Wallet option — VendStack fetches the customer's bank account and shows it so they can top up), /authenticate (checks up front whether a number has an account, so an unrecognized customer is sent straight to sign-up instead of after a failed purchase), and /register (lets a new customer create an account in chat when their number isn't recognized).
Authentication
Every request from us carries three headers:
| Header | Meaning |
|---|---|
X-VendStack-Key |
Your API key — a public connection identifier (shown on the dashboard's Merchant API card). Not a secret; use it to tell which of your connections a request is for. |
X-VendStack-Timestamp |
Unix seconds when we signed the request |
X-VendStack-Signature |
HMAC-SHA256 signature (hex) |
Both the API key and the signing secret are on the dashboard: Integrations → Merchant API. You verify requests with the signing secret below — the key is just an identifier.
The signature is computed over this exact string, joined by dots:
{timestamp}.POST.{path}.{rawBody}
where path is the request path (e.g. /vend) and rawBody is the raw JSON body. Recompute it with your signing secret (shown on the dashboard's Merchant API card) and compare — reject the request if it doesn't match or the timestamp is stale (say, older than 5 minutes).
PHP
$expected = hash_hmac('sha256',
$timestamp.'.POST.'.$path.'.'.$rawBody, // e.g. "1753631999.POST./vend.{...}"
$yourSigningSecret
);
if (! hash_equals($expected, $providedSignature)) {
abort(401);
}
Node
const expected = crypto.createHmac('sha256', signingSecret)
.update(`${timestamp}.POST.${path}.${rawBody}`)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided))) return res.sendStatus(401);
Conventions
- Amounts are whole naira (integers).
500= ₦500. referenceon a purchase is unique per order and idempotent — if you receive the samereferencetwice (a retry), return the original result; do not charge twice.- Respond
200with JSON. Reply within a few seconds — USSD sessions are short. - Timeouts. We wait up to 2 minutes for the money/verification calls (
/vend,/validate,/status) since provider networks can be slow to settle; catalog reads (/billers,/packages) use a shorter timeout. If a/vendtimes out, we reconcile via/status(below) before telling the customer anything — a purchase that succeeded on your side is never reported as a failure. - Return errors as JSON (see Errors); we turn them into a friendly message for the customer.
service values
Every request that names a product uses one of these exact strings in the service field — on every channel (USSD, WhatsApp, Telegram):
service |
Product | Customer identifier | Notes |
|---|---|---|---|
airtime |
Airtime top-up | recipient phone (msisdn) |
|
data |
Data bundles | recipient phone (msisdn) |
you resolve the network from the number |
cable |
Cable / TV (DStv, GOtv, StarTimes) | smartcard (customer_id) |
|
power |
Electricity — prepaid/postpaid meters | meter number (customer_id) |
the value is always power, never electricity, even though customers say "electricity" |
betting |
Betting wallet funding | account/user ID (customer_id) |
|
internet |
An ISP's own internet plans | the payer's phone, or an account ID (customer_id) |
ISP websites only — paid through your gateway, never a wallet. See below |
Electricity is sent as
power. WhatsApp/Telegram customers naturally say "electricity", but VendStack always normalizes it topowerbefore calling you — so you only ever branch on the five values above.
Endpoints
POST /billers
When we need your list of providers for a service (cable/power/betting).
Request
{ "service": "cable" }
Response
{ "billers": [
{ "id": "dstv", "label": "DSTV" },
{ "id": "gotv", "label": "GOTV" }
] }
On USSD the customer picks a provider from this list, so we send you its id. On WhatsApp/Telegram they say the provider ("AEDC", "Ikeja Electric", "DStv") — we match that against this list (by label/id) and send you the matching id in /validate and /vend, just the same. So keep your labels recognizable (include the common name/acronym), and /billers is what makes chat channels resolve to your ids. If we can't match one, we fall back to sending the raw name.
POST /packages
Fixed-price items — data bundles (by phone number) or cable bouquets (by biller). You resolve the network for data from the number.
Request (data)
{ "service": "data", "msisdn": "08012345678" }
Request (cable)
{ "service": "cable", "biller": "dstv" }
Response
{ "network": "MTN", "packages": [
{ "id": "d1gb", "label": "1GB / 30 days", "amount": 300 },
{ "id": "d2gb", "label": "2GB / 30 days", "amount": 500 }
] }
network is optional (used for data). Each package amount is the price the customer pays.
POST /validate
Confirm a customer identifier (smartcard / meter / betting-ID) so we can show the account holder before charging.
Request
{ "service": "power", "biller": "ekedc", "customer_id": "45700012345" }
Response
{ "valid": true, "name": "ADA OKAFOR", "address": "12 Adeniyi Jones Ave, Ikeja, Lagos", "min_amount": 1000, "max_amount": 500000 }
Return { "valid": false } if the identifier is unknown. If you can't return a name, send { "valid": true, "name": "" } and we'll confirm using the raw identifier. address is optional — when you return it (e.g. the metered premises), it appears on the customer's receipt.
min_amount / max_amount (optional, whole naira) — per-meter vend limits (e.g. a prepaid disco floor/ceiling). When you return them, VendStack shows them to the customer at confirmation and rejects any amount outside the range before /vend. If you omit them, prepaid electricity falls back to a configurable ₦1,000 minimum. (These map straight from BuyPower's minVendAmount/maxVendAmount if you proxy that.)
POST /vend
Complete the purchase. Validate the PIN against customer_msisdn and debit that wallet here. Only the fields relevant to the service are present.
Request (data example)
{
"reference": "VS-20260727-1A2B3C4D",
"service": "data",
"amount": 300,
"convenience_fee": 0,
"total": 300,
"pin": "1234",
"customer_msisdn": "2348030000000",
"msisdn": "08012345678",
"network": "MTN",
"package_id": "d1gb"
}
customer_msisdn is the payer — the phone number of the wallet owner making the purchase (their WhatsApp number, or the USSD caller). It is always present, and the PIN and wallet debit apply to this account. msisdn (when present) is the recipient of airtime/data, which may differ from the payer.
amount, convenience_fee and total — which one do I debit?
Every /vend carries all three, and they mean different things:
| Field | What it is | What to do with it |
|---|---|---|
amount |
The product cost | Vend this. ₦500 airtime, the ₦2,500 bundle, the ₦1,000 of power. |
convenience_fee |
Your flat surcharge on this channel | Yours to keep — it stays in your wallet as revenue. |
total |
amount + convenience_fee |
Debit this from the customer's wallet. |
Debit total, vend amount. With no convenience fee configured, convenience_fee is 0 and total equals amount, so a backend that only reads amount behaves exactly as it always has.
The fee is set by you, per channel, on that channel's setup page in the dashboard (₦0 = off). VendStack shows it to the customer, itemised, at the confirmation step — before they enter a PIN — so nobody is surprised by the charge:
Airtime ₦500 to 08031112222
Fee: ₦20
Total: ₦520
Enter PIN to confirm:
⚠️ If your /vend debits amount and ignores total, you will never actually collect the fee — the customer is told they paid ₦520 while your wallet only takes ₦500. Set a fee only once your backend reads total.
Scheduled and recurring runs carry the fee too, at whatever rate you have configured at run time — so turning the fee off stops charging existing schedules on their next cycle.
Field guide by service:
| Field | Airtime | Data | Cable | Power | Betting |
|---|---|---|---|---|---|
customer_msisdn (payer / wallet owner) |
✅ | ✅ | ✅ | ✅ | ✅ |
msisdn (recipient phone) |
✅ | ✅ | – | – | – |
network |
– | ✅ | – | – | – |
biller |
– | – | ✅ | ✅ | ✅ |
customer_id (smartcard/meter/account) |
– | – | ✅ | ✅ | ✅ |
meter_type (prepaid/postpaid) |
– | – | – | ✅ | – |
package_id |
– | ✅ | ✅ | – | – |
product (optional) |
✅ | ✅ | ✅ | ✅ | ✅ |
amount (product cost — vend this) |
✅ | ✅ | ✅ | ✅ | ✅ |
convenience_fee (your surcharge, 0 when off) |
✅ | ✅ | ✅ | ✅ | ✅ |
total (amount + fee — debit this) |
✅ | ✅ | ✅ | ✅ | ✅ |
pin |
✅ | ✅ | ✅ | ✅ | ✅ |
biller is your own id from /billers (e.g. "ekedc", "dstv") on every channel — we resolve chat-typed provider names to it for you (see /billers above).
product is an optional free-text label (e.g. "5GB") sent when a customer names a bundle over WhatsApp without picking from /packages. Use it to resolve the item when there's no package_id.
Sample /vend request for each product
Real payloads you can copy for testing — one per service:
Airtime — recharge a phone (may differ from the payer):
{
"reference": "VS-20260814-A1B2C3D4",
"service": "airtime",
"amount": 200,
"convenience_fee": 20,
"total": 220,
"pin": "1234",
"customer_msisdn": "2348030000000",
"msisdn": "08031112222",
"product": "Airtime"
}
Data — a bundle for a phone; network and package_id come from /packages:
{
"reference": "VS-20260814-B2C3D4E5",
"service": "data",
"amount": 2500,
"pin": "1234",
"customer_msisdn": "2348030000000",
"msisdn": "08031112222",
"network": "MTN",
"package_id": "d5gb",
"product": "5GB / 30 days"
}
Cable — a bouquet on a smartcard; biller/package_id are your ids:
{
"reference": "VS-20260814-C3D4E5F6",
"service": "cable",
"amount": 10500,
"pin": "1234",
"customer_msisdn": "2348030000000",
"biller": "dstv",
"customer_id": "4567890123",
"package_id": "compact",
"product": "DStv Compact"
}
Power (electricity) — a meter recharge; note meter_type and that service is power:
{
"reference": "VS-20260814-D4E5F6A7",
"service": "power",
"amount": 5000,
"pin": "1234",
"customer_msisdn": "2348030000000",
"biller": "ekedc",
"customer_id": "45700012345",
"meter_type": "prepaid",
"product": "Eko Electric"
}
Betting — fund a betting account:
{
"reference": "VS-20260814-E5F6A7B8",
"service": "betting",
"amount": 3000,
"pin": "1234",
"customer_msisdn": "2348030000000",
"biller": "bet9ja",
"customer_id": "8899776",
"product": "Bet9ja"
}
Scheduled & recurring runs (PIN-less)
Customers can schedule a purchase for later or make it recurring. When VendStack runs one of these on schedule, the customer isn't present to enter a PIN — so the call arrives with no pin and an extra field:
{
"reference": "VS-20260805-9F8E7D6C",
"service": "data",
"amount": 2500,
"customer_msisdn": "2348030000000",
"msisdn": "08012345678",
"network": "MTN",
"package_id": "d5gb",
"authorization": "scheduled"
}
authorization: "scheduled"marks a purchase the customer pre-authorised at setup. For these, debit thecustomer_msisdnwallet without a PIN — the request's HMAC signature is your proof it's genuinely from VendStack. Everything else (idempotency onreference, the response shape) is identical to a normal vend.- To support scheduling, your
/vendmust accept a signed request with nopinwhenauthorizationis present. If your backend rejects PIN-less vends, scheduled and recurring orders will fail. If you never want unattended debits, simply reject any request carryingauthorizationand the feature stays off. - VendStack never stores a customer's PIN — not even for recurring orders (CLAUDE.md safety model).
Response
{ "success": true, "message": "Delivered", "reference": "VS-20260727-1A2B3C4D", "balance": 16250, "token": "1234-5678-9012", "units": "238.1 kWh" }
success— did it go through?message— shown to the customer on failure (e.g."Incorrect PIN","Insufficient balance").balance(optional) — wallet balance after, shown to the customer.token(optional) — prepaid electricity token, shown to the customer and printed on the PDF receipt.units(optional) — units credited (e.g."238.1 kWh"), shown beside the token on the receipt.error_code(optional) — a machine-readable failure reason. Return"invalid_pin"on a wrong PIN so WhatsApp lets the customer retry instead of ending the chat. Other values (e.g."insufficient_funds") end the transaction with yourmessage.
Internet plans (ISPs)
If your website's business type is Internet service provider, VendStack sells your plans on WhatsApp and Telegram and collects payment through the gateway you connect on Integrations → Payment provider (Paystack, Monnify, Flutterwave, Credo, Squad or Kora). The checkout is opened with your keys, so the money settles straight into your gateway account — VendStack never holds it.
The flow, and what we call on your backend at each step:
- The customer asks to renew. We call
/authenticate(optional) with their chat number. If you answer{ "exists": false }we ask them for the phone number or account ID their internet is registered under, and check it with/validate({ "service": "internet", "customer_id": "10024" }→{ "valid": true, "name": "ADA OKAFOR" }). - We call
/packagesand show your plans with prices:{ "service": "internet", "msisdn": "2348031112222" }msisdnis the customer's number — or the account ID they gave in step 1 — so you can answer with that customer's plan(s) rather than your whole price list. Put the duration in the label ("Speed Wave — 1 month","Speed Wave — 3 months"); each package is one fixed price. - The customer picks a plan, confirms, and pays on your gateway's checkout page.
- We confirm the payment server-to-server with your gateway (never from the customer's browser or an unverified webhook), check it is in naira and covers the full amount, and only then call
/vend:
{
"reference": "VS-20260918-9F8E7D6C",
"service": "internet",
"amount": 30000,
"convenience_fee": 0,
"total": 30000,
"customer_msisdn": "2348031112222",
"customer_id": "10024",
"package_id": "speed-wave-1m",
"product": "Speed Wave — 1 month",
"authorization": "paid",
"payment": {
"provider": "paystack",
"reference": "VS-20260918-9F8E7D6C",
"provider_reference": "4099260516",
"amount_paid": 30000,
"paid_at": "2026-09-18T14:20:05+01:00"
}
}
- There is no
pin, and nothing to debit — the customer has already paid you.authorization: "paid"plus the request's HMAC signature is your instruction to activate the plan. payment.referenceis the reference the payment was made under on your own gateway account (it equalsreference), so you can look the transaction up yourself before activating if you want a second check.customer_idis present only when the customer named a different account in step 1; otherwise activate the plan forcustomer_msisdn.- Idempotency matters more here than anywhere. If we can't reach you, we keep retrying the same
referencefor a while — the customer has paid, so we never give up silently. Return the original result for a repeatedreference; never extend the subscription twice. - If you return
success: false, the customer is told their payment was received but the plan wasn't activated, and the order appears on your Payments page under Paid — not delivered with yourmessage. Fix the cause and press Retry delivery — we send the samereferenceagain. Refunds, if you decide on one, are issued by you from your gateway.
Test mode. While your store is in test mode, connect your gateway's test keys: customers (you) get a real test checkout and the whole loop runs, but /vend is not called. Live stores require live keys — a live store with test keys issues no payment links, since test cards would otherwise buy real plans.
Webhook URL. The Payments page shows a webhook URL unique to your website. Paste it into your gateway's webhook settings so plans activate the instant a payment lands. Without it everything still works — we poll your gateway — but activation can lag by up to a minute.
Not supported for internet: USSD (a payment link can't be tapped from a USSD screen), scheduled/recurring orders, /balance, /accounts and /register (there is no wallet).
POST /status (optional, recommended)
Reconcile a purchase after a timeout. If our /vend call doesn't return in time (a slow network), the purchase may still have completed on your side — so before telling the customer anything, we call /status with the same reference to learn what really happened.
Request
{ "reference": "VS-20260727-1A2B3C4D" }
Response
{ "status": "completed", "balance": 16250, "token": "1234-5678-9012", "units": "238.1 kWh" }
status is one of:
"completed"— it went through. We tell the customer it succeeded and show thetoken/units/balanceif present, exactly like/vend."failed"(also"not_found","reversed") — it definitively did not go through. We tell the customer it failed."pending"/ anything else — still processing or unknown. We tell the customer we couldn't confirm and ask them to check before retrying.
Look the transaction up by reference (the same idempotency key from /vend). Skip this endpoint and a timed-out vend is simply reported to the customer as unconfirmed ("please check before trying again") — never a false failure, but also no positive confirmation. Implementing /status makes timeouts resolve cleanly.
POST /balance (optional)
Return a wallet balance for a phone number. Implement it and WhatsApp shows the customer their balance before they confirm; skip it and that line is simply omitted. Never required for a purchase.
Request
{ "msisdn": "2348030000000" }
Response
{ "balance": 18750 }
Whole naira, as an integer. Return 4xx (or omit balance) if you don't support it — we treat that as "not available" and move on.
POST /accounts (optional)
Return the bank account(s) a customer can transfer to in order to fund their wallet on your platform. This powers the Fund Wallet menu option (and the WhatsApp "fund my wallet" request): VendStack calls this with the customer's phone number, then displays the account(s) so they can transfer. VendStack never moves the money — you credit the customer's wallet when the transfer lands (via your own bank/virtual-account webhook).
Request
{ "customer_msisdn": "2348030000000" }
Response
{
"accounts": [
{ "bank": "Wema Bank", "account_number": "7020001111", "account_name": "BuyVTU / Ada Okafor" }
]
}
Return one or more accounts (e.g. a dedicated virtual account per customer). Return an empty accounts array (or a 4xx) if the customer has no funding account yet — the customer is told to contact support. Skip this endpoint entirely and the Fund Wallet option simply reports that funding isn't available.
POST /authenticate (optional, recommended)
Tell VendStack up front whether a phone number already has an account. When you implement this, a customer whose number isn't recognized is sent straight to sign-up at the start of the conversation — before they pick a product, confirm, and enter a PIN — instead of hitting a dead-end at purchase time. Skip it and VendStack falls back to discovering "no account" from the /vend response (error_code: "account_not_found"), exactly as before.
Request
{ "phone": "2348030000000" }
Response
{ "exists": true }
Return { "exists": true } if the number has an account, { "exists": false } if it doesn't. Be precise: VendStack only diverts a customer to sign-up on an explicit "exists": false from an HTTP 200. Anything else — a 4xx/5xx, an unreachable endpoint, or a body without an exists field — is treated as "unknown", and the conversation proceeds normally (the post-vend fallback still catches an unregistered payer). Pair this with /register so the sign-up you route customers into can actually complete.
POST /register (optional)
Create an account for a new customer. When a number has no account — detected up front via /authenticate, or from the /vend response (error_code: "account_not_found") — VendStack offers a short sign-up on WhatsApp/Telegram — it collects the customer's name and a transaction PIN (the phone number is already known) — and posts them here.
Request
{ "phone": "2348030000000", "name": "Ada Okafor", "pin": "1234" }
Response
{ "success": true }
Create the account with phone as the identity and pin as their transaction PIN, then return { "success": true }. On failure return { "success": false, "message": "…" } (shown to the customer), or a 4xx. Skip this endpoint entirely and sign-up isn't offered — an unregistered customer is simply told no account was found.
Errors
For validation/business failures on /vend, return success: false with a clear message (and, where it helps, an error_code):
{ "success": false, "message": "Incorrect PIN", "error_code": "invalid_pin" }
error_code: "invalid_pin" lets WhatsApp offer the customer another PIN attempt. error_code: "account_not_found" tells us the payer has no account, so we offer them the sign-up above (implement /register for this; without it we just report no account). Any other failure ends the transaction with your message.
For unexpected failures, return HTTP 4xx/5xx with a JSON body:
{ "error": { "message": "Meter is inactive" } }
We surface message / error.message to the customer; never leak internal details.
Webhooks (optional)
If you set a Webhook URL on the Merchant API card, VendStack POSTs an event there after every terminal transaction, so your systems stay in sync without polling. This is the return signal to complement the /vend call we make to you.
Each delivery is signed the same way as our requests — verify the X-VendStack-Signature header (HMAC-SHA256 of timestamp.rawBody with your signing secret) and reject anything that doesn't match or is stale.
Headers: X-VendStack-Event, X-VendStack-Timestamp, X-VendStack-Signature.
Body
{
"event": "transaction.completed",
"status": "completed",
"reference": "VS-20260727-1A2B3C4D",
"channel": "whatsapp",
"customer_msisdn": "2348030000000",
"recipient_msisdn": "08012345678",
"service": "data",
"network": "MTN",
"product": "5GB",
"amount": 2500,
"occurred_at": "2026-07-27T14:20:05+01:00"
}
eventistransaction.completedortransaction.failed.referencematches the one from the original/vend— use it to reconcile.- Return
2xxto acknowledge; we retry a few times on failure.
Testing
A starter kit gets the signature check, the idempotency guard and the response shapes right by default — worth starting from even if you rewrite it later.
Point your base URL at a sandbox that returns the shapes above. You can exercise a full USSD session by POSTing to our callback (we send sessionId, phoneNumber, serviceCode, text) — or ask us to run a test session against your endpoints.
Going live
- Your Merchant API is saved in the dashboard (base URL + signing secret) — this powers every channel.
- All endpoints your products need respond over HTTPS within a few seconds.
- You verify the signature on every request and reject stale/invalid ones.
-
/vendvalidates the PIN againstcustomer_msisdn, debits that wallet, and is idempotent onreference. - (For scheduling)
/vendaccepts a signed PIN-less request whenauthorization: "scheduled"is present, and debits on the strength of the signature. -
/validatereturns real account names (recommended — customers confirm before paying). - (Recommended)
/statuslooks a purchase up byreferenceso timed-out vends reconcile cleanly instead of being reported as unconfirmed. - Errors return a customer-safe
message(anderror_code: "invalid_pin"for wrong PINs). - (Optional) If you set a Webhook URL, you verify its signature and reconcile on
reference.
Your store starts in test mode: customers can run the whole flow (menus, confirmation, PIN) but purchases are not sent to your backend — perfect for rehearsing without moving money. When the checklist above passes, open Dashboard → Integrations and click Go live. From then on real purchases hit your /vend; share your code (*347*321*1#), WhatsApp number or Telegram bot with your customers. You can switch back to test at any time.