Loading…

Starter Kits

A single file you drop into your backend that implements the whole Merchant API for you — signature verification, routing, idempotency, and the exact JSON shapes. You write only your wallet and vending logic.

None of this is required. The Merchant API is plain JSON over HTTPS and you can implement it by hand. These kits just save you the fiddly parts, and get the security details right by default.

Download

Stack File Drop it at
Laravel 10, 11, 12, 13 VendStack.php + VendStackHandler.php app/Services/
PHP 8.1+, no framework VendStack.php anywhere in your project
Node.js 18+ / Express 4–5 vendstack.cjs src/vendstack.cjs
ASP.NET Core .NET 6+ VendStack.cs Integrations/VendStack.cs

Each file is self-contained, dependency-free, and carries its own install instructions in the header comment.

What the kit does for you

  • Verifies every request. HMAC-SHA256 over {timestamp}.POST.{path}.{rawBody}, compared in constant time, with requests older than 5 minutes rejected as replays. Getting this wrong is the single most common integration bug — the signed path is /vend, not the prefix you mounted it under, and the kit handles that.
  • Makes /vend idempotent. A retried order carries the same reference. The kit replays the original response instead of running your handler twice, and blocks two concurrent copies of the same reference. Without this, a network hiccup on our side can charge your customer twice.
  • Publishes all nine endpoints, and answers "not implemented" for the ones you skip — so an airtime-only merchant writes one method and the rest degrade safely.
  • Parses the payload into typed fields, including the amount / convenience_fee / total distinction that decides whether you actually collect your fee.
  • Keeps PINs out of your logs, and turns a thrown exception into a clean JSON error rather than a stack trace.

Laravel

What you're installing

Two files. You never edit the first one:

File What it is
app/Services/VendStack.php The kit. Routes, signature checks, idempotency. Never edit it.
app/Services/VendStackHandler.php Your vending logic — a stub with every method already written out and marked TODO. This is the only file you touch.

Step 1 — copy both files in

Download them above into app/Services/.

Step 2 — add one line to routes/web.php

\App\Services\VendStack::routes(\App\Services\VendStackHandler::class);

That publishes all nine endpoints. They're automatically exempt from CSRF, so this works even though it's in web.php. Confirm with:

php artisan route:list --path=vendstack

Step 3 — add your signing secret

In .env:

VENDSTACK_SECRET=whsec_xxxxxxxxxxxxxxxx

And in the array in config/services.php, so it survives php artisan config:cache:

'vendstack' => ['secret' => env('VENDSTACK_SECRET')],

Then set your base URL on the Merchant API card to https://your-domain.com/vendstack.

Step 4 — fill in vend()

Open VendStackHandler.php. It already contains all nine methods with comments explaining each one. Delete the ones you don't need — an airtime-only merchant keeps just vend().

Here's vend() completed for a typical wallet:

public function vend(VendStackRequest $r): array
{
    // $r->customerMsisdn() is the PAYER — the wallet to check and debit.
    $wallet = Wallet::where('phone', $r->customerMsisdn())->first();

    if ($wallet === null) {
        return VendStack::failed('No account found for this number.', 'account_not_found');
    }

    // Scheduled/recurring runs have no PIN — the customer pre-authorised them.
    if (! $r->isScheduled() && ! Hash::check((string) $r->pin(), $wallet->pin_hash)) {
        return VendStack::failed('Incorrect PIN.', 'invalid_pin');   // customer gets another try
    }

    // Check against TOTAL (product + your convenience fee), not amount.
    if ($wallet->balance < $r->total()) {
        return VendStack::failed('Insufficient balance.', 'insufficient_funds');
    }

    // $r->msisdn() is the RECIPIENT, which may be someone other than the payer.
    $result = $vendor->airtime($r->msisdn(), $r->amount());

    if (! $result->successful) {
        return VendStack::failed($result->message);   // nothing debited
    }

    $wallet->decrement('balance', $r->total());       // debit TOTAL

    return VendStack::done('Delivered', balance: $wallet->fresh()->balance);
}

You don't check for duplicate references — the kit already guarantees vend() runs once per reference, even when we retry.

What happens at runtime

A customer types "send 500 airtime to 08031112222" on WhatsApp, confirms, and enters their PIN. We POST to your app:

POST https://your-domain.com/vendstack/vend
X-VendStack-Timestamp: 1788340601
X-VendStack-Signature: a1c11b50e960dc38...

{"reference":"VS-20260814-A1B2C3D4","service":"airtime","amount":500,
 "convenience_fee":20,"total":520,"pin":"1234",
 "customer_msisdn":"2348030000000","msisdn":"08031112222"}

The kit verifies the signature, rejects it if it's a replay or a duplicate order, wraps the JSON in a VendStackRequest, and calls your vend($r). Your VendStack::done('Delivered', balance: 16250) becomes the success message the customer reads. You never write a route, a controller, a signature check or a JSON response.

Which methods do I need?

Selling… Fill in
Airtime vend()
Data vend() + packages()
Cable TV vend() + packages() + billers() + validate()
Electricity vend() + billers() + validate()
Betting vend() + billers() + validate()

status(), balance(), accounts(), authenticate() and register() are optional polish. Delete any method you don't want — VendStack drops that feature rather than failing a purchase.

PHP (no framework)

Route everything under /vendstack/ to one front controller, then:

<?php
require __DIR__.'/VendStack.php';

$vendstack = new VendStack(getenv('VENDSTACK_SECRET'));

$vendstack->on('vend', function (VendStackRequest $r) {
    $wallet = my_find_wallet($r->customerMsisdn());

    if (! $wallet || ! password_verify((string) $r->pin(), $wallet['pin_hash'])) {
        return VendStack::failed('Incorrect PIN', 'invalid_pin');
    }

    if ($wallet['balance'] < $r->total()) {
        return VendStack::failed('Insufficient balance', 'insufficient_funds');
    }

    $balance = my_debit($wallet['id'], $r->total());
    $token   = my_vendor_send($r->service(), $r->msisdn(), $r->amount());

    return VendStack::done('Delivered', $balance, $token);
});

$vendstack->serve();

serve() verifies, dispatches, echoes the JSON and exits. If you're inside a framework that owns the response, call respond($endpoint, $body, $headers) instead — it returns [statusCode, array] and touches nothing else.

Idempotency records go to the system temp directory by default. Running more than one web server? Pass a shared, durable directory as the second constructor argument, or a retry that lands on another machine can charge twice.

Node.js

const express = require('express');
const { vendstack, done, failed, balance } = require('./vendstack.cjs');

const app = express();

app.use('/vendstack', vendstack({
  secret: process.env.VENDSTACK_SECRET,

  async vend(r) {
    const wallet = await findWallet(r.customerMsisdn);

    if (!wallet || !(await verifyPin(r.pin, wallet.pinHash))) {
      return failed('Incorrect PIN', 'invalid_pin');
    }

    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));
  },
}));

The .cjs extension is deliberate — it loads from both CommonJS (require) and ESM (import) projects.

Mount the router before any global express.json(), or give it its own path as above. The signature covers the exact bytes we sent, and a re-serialized body is not byte-identical.

The default idempotency store is per-process memory. Running several instances? Pass a store with the same { get, set } shape backed by Redis or your database.

ASP.NET Core

using VendStack;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<MyVendStackHandler>();

var app = builder.Build();
app.MapVendStack<MyVendStackHandler>();   // defaults to /vendstack
app.Run();

VendAsync is the only required override; everything else defaults to "not implemented":

public sealed class MyVendStackHandler(WalletService wallets, IVendor vendor) : VendStackHandler
{
    public override async Task<object?> VendAsync(VendStackRequest r)
    {
        var wallet = await wallets.FindAsync(r.CustomerMsisdn);

        if (wallet is null || !wallets.PinMatches(wallet, r.Pin))
        {
            return VendStackReply.Failed("Incorrect PIN", "invalid_pin");
        }

        if (wallet.Balance < r.Total)
        {
            return VendStackReply.Failed("Insufficient balance", "insufficient_funds");
        }

        var remaining = await wallets.DebitAsync(wallet, r.Total);
        var result = await vendor.SendAsync(r.Service, r.Msisdn, r.Amount);

        return VendStackReply.Done("Delivered", balance: remaining, token: result.Token);
    }
}

Put the secret in appsettings.json as VendStack:Secret, or pass it to MapVendStack. The default idempotency store is per-process memory — pass an IVendStackStore backed by Redis or your database if you run more than one instance.

Webhooks

If you set a Webhook URL on the Merchant API card, we POST an event to it after every terminal transaction. Webhooks are signed differently from requests — {timestamp}.{rawBody}, with no method or path — so every kit ships a separate verifier:

Stack Call
Laravel VendStack::verifyWebhook($request)
PHP VendStack::verifyWebhook($timestamp, $signature, $body, $secret)
Node.js verifyWebhook(timestamp, signature, body, secret)
ASP.NET Core VendStackSignature.VerifyWebhook(timestamp, signature, body, secret)

Two things the kit can't decide for you

Debit total, vend amount. Every /vend carries amount (the product cost), convenience_fee (your surcharge on that channel) and total (the sum). We show the customer the total before they enter a PIN. If your handler debits amount, you never collect the fee. With no fee configured the two are equal, so reading total is always right.

Scheduled and recurring orders have no PIN. When a customer pre-authorises a repeat purchase, the run arrives with authorization: "scheduled" and no pin — they aren't there to type one. Check isScheduled() and debit on the strength of the signature, which is your proof the request is genuinely ours. Don't want unattended debits? Reject those requests and the feature stays off.

Next

→ Merchant API reference — the full contract, in case you'd rather implement it by hand or need a field the kit doesn't surface.