'use strict'; /** * VendStack — Node.js Merchant API integration kit (single file, no dependencies * beyond Express, which you almost certainly already have). * * VendStack calls YOU. This file exposes every endpoint VendStack needs, * verifies the HMAC signature on each request, enforces idempotency on /vend, * and shapes your replies into the exact JSON the contract expects. You supply * the wallet + vending logic; everything else is here. * * --------------------------------------------------------------------------- * INSTALL * --------------------------------------------------------------------------- * 1. Drop this file in your project (e.g. src/vendstack.cjs). The .cjs extension * is deliberate: it loads from both CommonJS and ESM projects, whichever * yours is. * * CommonJS: const { vendstack, done } = require('./vendstack.cjs'); * ESM: import { vendstack, done } from './vendstack.cjs'; * 2. Put your signing secret (Dashboard -> Integrations -> Merchant API) in the * environment as VENDSTACK_SECRET. * 3. Mount the router and give VendStack the base URL * https://your-domain.com/vendstack on the Merchant API card. * * const express = require('express'); * const { vendstack, done, failed, balance, packages } = require('./vendstack.cjs'); * * const app = express(); * * app.use('/vendstack', vendstack({ * secret: process.env.VENDSTACK_SECRET, * * // Implement only what your products need. Airtime only? Just vend. * async vend(r) { * const wallet = await findWallet(r.customerMsisdn); * * if (!wallet || !(await verifyPin(r.pin, wallet.pinHash))) { * return failed('Incorrect PIN', 'invalid_pin'); * } * * // Debit TOTAL (product + your convenience fee), vend AMOUNT. * if (wallet.balance < r.total) { * return failed('Insufficient balance', 'insufficient_funds'); * } * * const remaining = await debit(wallet.id, r.total); * const { token } = await vendor.send(r.service, r.msisdn, r.amount); * * return done('Delivered', { balance: remaining, token }); * }, * * async balance(r) { * return balance(await balanceOf(r.msisdn)); * }, * * async packages(r) { * const bundles = await bundlesFor(r.msisdn); * return packages(bundles, networkOf(r.msisdn)); * }, * })); * * app.listen(3000); * * IMPORTANT: mount this BEFORE any global express.json() body parser, or give * the router its own path as above. The signature is computed over the exact * bytes VendStack sent, and re-serialized JSON is not byte-identical. * * Handlers you can supply: billers, packages, validate, vend, status, balance, * accounts, authenticate, register. Anything you leave out is reported as "not * implemented" and the feature degrades safely. * * --------------------------------------------------------------------------- * WHAT THIS FILE GUARANTEES SO YOU DON'T HAVE TO * --------------------------------------------------------------------------- * - Every request is HMAC-SHA256 verified against "{ts}.POST.{path}.{body}" * in constant time, and rejected if the timestamp is more than 5 minutes old. * - /vend is idempotent on `reference`: a retry of an order you already * answered replays the original response instead of charging twice, and an * in-flight guard blocks two concurrent copies of the same reference. * - PINs are never written to logs. * - Scheduled/recurring runs arrive with no PIN and r.isScheduled === true; * debit on the strength of the signature. * - A thrown handler becomes a clean JSON error, never a stack trace. * * @see https://vendstack.com/docs/merchant-api */ const crypto = require('crypto'); const express = require('express'); /** The endpoints VendStack may call. */ const ENDPOINTS = [ 'billers', 'packages', 'validate', 'vend', 'status', 'balance', 'accounts', 'authenticate', 'register', ]; /** Reject a request signed longer ago than this (seconds). */ const TIMESTAMP_TOLERANCE = 300; /** How long a completed /vend response is replayed for a repeated reference (ms). */ const IDEMPOTENCY_TTL = 86_400_000; /** * Build the Express router carrying every VendStack endpoint. * * @param {object} options * @param {string} options.secret signing secret from the Merchant API card * @param {object} [options.store] idempotency store; see MemoryStore below. * The default is per-process memory, which * is fine for a single instance. Running * several? Pass a Redis/DB-backed store with * the same { get, set } shape, or a retry * that lands on another instance can charge * the customer twice. * @param {function} [options.onError] called with (error, endpoint) when a handler throws * @returns {import('express').Router} */ function vendstack(options = {}) { const { secret, store = new MemoryStore(), onError } = options; if (!secret) { throw new Error('VendStack: a signing secret is required (Dashboard -> Integrations -> Merchant API).'); } const router = express.Router(); // Capture the exact bytes VendStack sent — the signature is computed over // them, and JSON.parse + JSON.stringify does not round-trip byte-for-byte. router.use(express.raw({ type: () => true, limit: '256kb' })); for (const endpoint of ENDPOINTS) { router.post(`/${endpoint}`, async (req, res) => { const body = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : ''; const verified = verify( endpoint, req.get('X-VendStack-Timestamp'), req.get('X-VendStack-Signature'), body, secret, ); if (!verified) { return res.status(401).json({ error: { message: 'Invalid signature.' } }); } let payload; try { payload = JSON.parse(body); } catch { return res.status(400).json({ error: { message: 'Malformed JSON body.' } }); } const handler = options[endpoint]; if (typeof handler !== 'function') { // Not implemented is a normal, safe answer: VendStack degrades the // matching feature rather than failing the customer's purchase. return res.status(404).json({ error: { message: `The ${endpoint} endpoint is not implemented.` } }); } const request = new VendStackRequest(payload); try { const result = endpoint === 'vend' ? await once(store, request, () => handler(request)) : await handler(request); return res.json(result ?? {}); } catch (error) { log(error, endpoint, payload, onError); return res.status(500).json({ error: { message: 'Could not complete the request. Please try again.' } }); } }); } return router; } /** * Constant-time check that a request really came from VendStack. * * The signed string is "{timestamp}.POST.{path}.{rawBody}", where path is the * endpoint path only — "/vend", never the prefix you mounted it under. */ function verify(endpoint, timestamp, signature, body, secret) { if (!secret || !timestamp || !signature) { return false; } if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > TIMESTAMP_TOLERANCE) { return false; // stale or replayed } const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.POST./${String(endpoint).replace(/^\//, '')}.${body}`) .digest('hex'); return timingSafeEqual(expected, signature); } /** * Verify a VendStack webhook (transaction.completed / transaction.failed). * * Webhooks are signed over "{timestamp}.{rawBody}" — note there is no method or * path in the string, unlike the requests above. Mount it with a raw body * parser for the same reason the router uses one. * * app.post('/vendstack/webhook', express.raw({ type: '*\/*' }), (req, res) => { * const body = req.body.toString('utf8'); * * if (!verifyWebhook(req.get('X-VendStack-Timestamp'), req.get('X-VendStack-Signature'), body, secret)) { * return res.sendStatus(401); * } * * const event = JSON.parse(body); * await reconcile(event.reference, event.status); * * return res.sendStatus(204); * }); */ function verifyWebhook(timestamp, signature, body, secret) { if (!secret || !timestamp || !signature) { return false; } if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > TIMESTAMP_TOLERANCE) { return false; } const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex'); return timingSafeEqual(expected, signature); } // --------------------------------------------------------------------------- // Reply builders — return these from your handlers. // --------------------------------------------------------------------------- /** * A successful purchase. balance (wallet after), token/units (prepaid * electricity) are optional and shown to the customer when present. */ function done(message = 'Successful', { balance, token, units, reference } = {}) { return compact({ success: true, message, reference, balance, token, units }); } /** * A failed purchase. Return errorCode "invalid_pin" so the customer is offered * another PIN attempt instead of the chat ending, or "account_not_found" so * they are offered sign-up. */ function failed(message, errorCode) { return compact({ success: false, message, error_code: errorCode }); } /** * Reconciliation answer for /status. status is one of completed, failed, * not_found, reversed or pending. */ function status(status, { balance, token, units } = {}) { return compact({ status, balance, token, units }); } /** * Your providers for a service. Keep labels recognizable ("Eko Electric * (EKEDC)") — chat channels match what the customer types against them. */ function billers(list) { return { billers: list.map((b) => ({ id: String(b.id), label: String(b.label) })) }; } /** Fixed-price items. amount is whole naira — the price the customer pays. */ function packages(list, network) { return compact({ network, packages: list.map((p) => ({ id: String(p.id), label: String(p.label), amount: Number(p.amount) })), }); } /** * A recognized meter / smartcard / betting account. Pass minAmount/maxAmount * when the provider has vend limits — VendStack enforces them before /vend. */ function valid(name = '', { address, minAmount, maxAmount } = {}) { return compact({ valid: true, name, address, min_amount: minAmount, max_amount: maxAmount }); } function invalid() { return { valid: false }; } function balance(amount) { return { balance: Number(amount) }; } /** * Bank accounts the customer can transfer to in order to fund their wallet. * VendStack only displays these; you credit the wallet from your own * bank/virtual-account webhook when the money lands. */ function accounts(list) { return { accounts: list.map((a) => ({ bank: String(a.bank), account_number: String(a.accountNumber ?? a.account_number), account_name: String(a.accountName ?? a.account_name), })), }; } /** * Whether a phone number already has an account. Be precise: only an explicit * false routes the customer to sign-up. */ function accountExists(exists) { return { exists: Boolean(exists) }; } function registered(success, message) { return compact({ success: Boolean(success), message }); } // --------------------------------------------------------------------------- // Internals // --------------------------------------------------------------------------- /** * One inbound VendStack request. Every field the contract can send is exposed * as a property; the ones irrelevant to the service are undefined. */ class VendStackRequest { constructor(payload) { /** Unique per order and idempotent — the same reference is the same purchase. */ this.reference = payload.reference; /** One of airtime, data, cable, power, betting. Electricity is always "power". */ this.service = payload.service; /** The PRODUCT cost in whole naira — this is what you vend. */ this.amount = Number(payload.amount ?? 0); /** Your own flat surcharge on this channel. Yours to keep. 0 when off. */ this.convenienceFee = Number(payload.convenience_fee ?? 0); /** * amount + convenienceFee — DEBIT THIS from the customer's wallet. * * Debiting amount instead silently drops your convenience fee: the customer * is told they paid the total while you only collect the product cost. * With no fee configured the two are equal. */ this.total = Number(payload.total ?? this.amount + this.convenienceFee); /** The customer's transaction PIN. Validate it, never store or log it. Absent on scheduled runs. */ this.pin = payload.pin; /** The PAYER — the wallet owner. Validate the PIN against this number and debit it. */ this.customerMsisdn = payload.customer_msisdn; /** The RECIPIENT of airtime/data, which may differ from the payer. */ this.msisdn = payload.msisdn; this.network = payload.network; /** Your own biller id, as returned from /billers (e.g. "ekedc", "dstv"). */ this.biller = payload.biller; /** Smartcard / meter / betting account number. */ this.customerId = payload.customer_id; /** "prepaid" or "postpaid", on power only. */ this.meterType = payload.meter_type; /** Your own package id, as returned from /packages. */ this.packageId = payload.package_id; /** Free-text product label (e.g. "5GB") when a chat customer named a bundle without picking one. */ this.product = payload.product; /** Phone number, on /authenticate and /register. */ this.phone = payload.phone; /** Customer name, on /register. */ this.name = payload.name; /** * True when this is a scheduled or recurring run the customer pre-authorised. * * There is no PIN — the customer isn't there to type one. The HMAC * signature is your proof the request is genuine, so debit customerMsisdn * without a PIN. Reject these and the feature stays off. */ this.isScheduled = payload.authorization != null; /** The raw payload, for anything not surfaced above. */ this.raw = payload; } } /** * The default idempotency store: a per-process Map with TTL eviction. * * Good enough for one instance. Behind a load balancer, or across restarts, a * retry can land somewhere that has never seen the reference — pass your own * store backed by Redis or your database, with the same { get, set } shape. */ class MemoryStore { constructor() { this.entries = new Map(); } async get(key) { const entry = this.entries.get(key); if (!entry) { return undefined; } if (Date.now() > entry.expiresAt) { this.entries.delete(key); return undefined; } return entry.value; } async set(key, value, ttlMs) { this.entries.set(key, { value, expiresAt: Date.now() + ttlMs }); } } /** References currently being vended in this process, so a retry can't run alongside the original. */ const inFlight = new Set(); /** * Run a vend exactly once per reference. * * A network hiccup makes VendStack retry, and a retry carries the same * reference. We refuse a second concurrent copy, then cache the answer so a * later retry replays it rather than charging the customer twice. */ async function once(store, request, callback) { const { reference } = request; if (!reference) { return callback(); } const key = `vendstack:vend:${reference}`; const cached = await store.get(key); if (cached !== undefined) { return cached; } if (inFlight.has(key)) { // The first copy is still running. Say so rather than double-charging; // VendStack reconciles this order through /status. return failed('This order is already being processed.', 'in_progress'); } inFlight.add(key); try { const result = await callback(); await store.set(key, result ?? {}, IDEMPOTENCY_TTL); return result; } finally { inFlight.delete(key); } } /** Compare two hex digests without leaking their contents through timing. */ function timingSafeEqual(expected, provided) { const a = Buffer.from(String(expected), 'utf8'); const b = Buffer.from(String(provided), 'utf8'); // timingSafeEqual throws on a length mismatch, which is itself an answer — // but a wrong-length signature is already a rejection, so this leaks nothing. return a.length === b.length && crypto.timingSafeEqual(a, b); } /** Log without ever writing a customer's PIN. */ function log(error, endpoint, payload, onError) { if (typeof onError === 'function') { return onError(error, endpoint); } const { pin, ...safe } = payload ?? {}; console.error(`vendstack.${endpoint} failed: ${error.message}`, safe); } /** Drop null/undefined keys so optional fields are simply absent from the JSON. */ function compact(object) { return Object.fromEntries(Object.entries(object).filter(([, value]) => value != null && value !== '')); } module.exports = { vendstack, verify, verifyWebhook, VendStackRequest, MemoryStore, ENDPOINTS, // Reply builders done, failed, status, billers, packages, valid, invalid, balance, accounts, accountExists, registered, };