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 | ✅ | – | ✅ | ✅ |
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) |
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.
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.