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.
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. One more endpoint, /balance, is entirely optional — implement it and WhatsApp will show customers their wallet balance before they pay.
Authentication
Every request from us carries three headers:
| Header | Meaning |
|---|---|
X-VendStack-Key |
Your connection identifier |
X-VendStack-Timestamp |
Unix seconds when we signed the request |
X-VendStack-Signature |
HMAC-SHA256 signature (hex) |
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. - Return errors as JSON (see Errors); we turn them into a friendly message for the customer.
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" }
] }
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" }
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.
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,
"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.
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 |
✅ | ✅ | ✅ | ✅ | ✅ |
pin |
✅ | ✅ | ✅ | ✅ | ✅ |
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.
Response
{ "success": true, "message": "Delivered", "reference": "VS-20260727-1A2B3C4D", "balance": 16250, "token": "1234-5678-9012" }
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.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 /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.
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; 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.
Testing
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. -
/validatereturns real account names (recommended — customers confirm before paying). - Errors return a customer-safe
message(anderror_code: "invalid_pin"for wrong PINs).
Once these pass, flip a channel to live in the dashboard and share your code (*332*1#) or WhatsApp number with your customers.