Integrations -> Merchant API) in .env: * * VENDSTACK_SECRET=whsec_xxxxxxxxxxxxxxxx * * and add this line to the array in config/services.php, so it survives * `php artisan config:cache` (env() returns null once config is cached): * * 'vendstack' => ['secret' => env('VENDSTACK_SECRET')], * * 4. Confirm it worked: * * php artisan route:list --path=vendstack * * You should see nine POST routes. Then set your base URL on the Merchant * API card to https://your-domain.com/vendstack * * Now open VendStackHandler.php and fill in vend(). Nothing else to write. * * --------------------------------------------------------------------------- * WHAT ACTUALLY HAPPENS AT RUNTIME * --------------------------------------------------------------------------- * A customer types "send 500 airtime to 08031112222" on WhatsApp, confirms, * and enters their PIN. VendStack then POSTs 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"} * * This file checks the signature, blocks a duplicate reference, wraps that JSON * in a VendStackRequest and calls YOUR VendStackHandler::vend($r). You return: * * return VendStack::done('Delivered', balance: 16250); * * and the customer sees a success message with their new balance. Return * VendStack::failed('Incorrect PIN', 'invalid_pin') and they get another try. * * You never write a route, a controller, a signature check or a JSON response. * * --------------------------------------------------------------------------- * THE NINE ENDPOINTS -> THE NINE HANDLER METHODS * --------------------------------------------------------------------------- * Implement only what your products need; a method you delete is reported to * VendStack as "not implemented" and that feature degrades safely. * * POST /vend -> vend() REQUIRED. Every product. * POST /packages -> packages() data bundles, cable bouquets * POST /billers -> billers() cable, power, betting * POST /validate -> validate() cable, power, betting * POST /status -> status() optional, recommended * POST /balance -> balance() optional * POST /accounts -> accounts() optional * POST /authenticate -> authenticate() optional * POST /register -> register() optional * * --------------------------------------------------------------------------- * 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. A cache * lock also blocks two concurrent copies of the same reference. * - PINs are never written to logs (see the redaction in log()). * - Scheduled/recurring runs arrive with no PIN and authorization="scheduled"; * check $r->isScheduled() and debit on the strength of the signature. * - Uncaught exceptions become a clean JSON error, never a stack trace. * * @see https://vendstack.com/docs/merchant-api */ final class VendStack { /** The endpoints VendStack may call, mapped to the handler method they invoke. */ public const ENDPOINTS = [ 'billers', 'packages', 'validate', 'vend', 'status', 'balance', 'accounts', 'authenticate', 'register', ]; /** Reject a request signed longer ago than this (seconds). */ public const TIMESTAMP_TOLERANCE = 300; /** How long a completed /vend response is replayed for a repeated reference. */ public const IDEMPOTENCY_TTL = 86400; /** * Register every VendStack endpoint under $prefix, pointed at your handler. * * @param class-string|object $handler your class holding the vending logic */ public static function routes(string|object $handler, string $prefix = 'vendstack'): void { foreach (self::ENDPOINTS as $endpoint) { Route::post($prefix.'/'.$endpoint, fn (Request $request) => self::handle($endpoint, $request, $handler)) ->withoutMiddleware(self::csrfMiddleware()) ->name('vendstack.'.$endpoint); } } /** * Run one endpoint end to end: verify the signature, build the request * object, call your handler, and shape whatever comes back into JSON. * * Exposed so you can mount the endpoints yourself (in a controller, on a * different path, behind your own middleware) instead of using routes(). */ public static function handle(string $endpoint, Request $request, string|object $handler): mixed { if (self::secret() === '') { // Say so plainly. Otherwise every request 401s and looks like a // signing bug rather than a missing setting. return response()->json(['error' => ['message' => 'VendStack signing secret is not configured. Set VENDSTACK_SECRET in .env and '. "add 'vendstack' => ['secret' => env('VENDSTACK_SECRET')] to config/services.php.", ]], 500); } $body = $request->getContent(); if (! self::verify($endpoint, $request->header('X-VendStack-Timestamp'), $request->header('X-VendStack-Signature'), $body)) { return response()->json(['error' => ['message' => 'Invalid signature.']], 401); } $payload = json_decode($body, true); if (! is_array($payload)) { return response()->json(['error' => ['message' => 'Malformed JSON body.']], 400); } $handler = is_string($handler) ? app($handler) : $handler; if (! method_exists($handler, $endpoint)) { // Not implemented is a normal, safe answer: VendStack degrades the // matching feature rather than failing the customer's purchase. return response()->json(['error' => ['message' => "The {$endpoint} endpoint is not implemented."]], 404); } $vendStackRequest = new VendStackRequest($payload); try { $result = $endpoint === 'vend' ? self::once($vendStackRequest, fn () => $handler->vend($vendStackRequest)) : $handler->{$endpoint}($vendStackRequest); } catch (\Throwable $e) { self::log('vendstack.'.$endpoint.' failed', ['error' => $e->getMessage()], $payload); return response()->json(['error' => ['message' => 'Could not complete the request. Please try again.']], 500); } return is_array($result) ? response()->json($result) : $result; } /** * 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. */ public static function verify(string $endpoint, ?string $timestamp, ?string $signature, string $body, ?string $secret = null): bool { $secret ??= self::secret(); if ($secret === '' || $timestamp === null || $signature === null) { return false; } if (abs(time() - (int) $timestamp) > self::TIMESTAMP_TOLERANCE) { return false; // stale or replayed } $expected = hash_hmac('sha256', $timestamp.'.POST./'.ltrim($endpoint, '/').'.'.$body, $secret); return hash_equals($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. * * Route::post('/vendstack/webhook', function (Request $request) { * abort_unless(VendStack::verifyWebhook($request), 401); * $event = $request->json()->all(); * Order::where('reference', $event['reference'])->update(['status' => $event['status']]); * return response()->noContent(); * }); */ public static function verifyWebhook(Request $request, ?string $secret = null): bool { $secret ??= self::secret(); $timestamp = $request->header('X-VendStack-Timestamp'); $signature = $request->header('X-VendStack-Signature'); if ($secret === '' || $timestamp === null || $signature === null) { return false; } if (abs(time() - (int) $timestamp) > self::TIMESTAMP_TOLERANCE) { return false; } return hash_equals(hash_hmac('sha256', $timestamp.'.'.$request->getContent(), $secret), $signature); } // ----------------------------------------------------------------------- // Reply builders — return these from your handler methods. // ----------------------------------------------------------------------- /** * A successful purchase. `balance` (wallet after), `token`/`units` (prepaid * electricity) are optional and shown to the customer when present. * * @return array */ public static function done(string $message = 'Successful', ?int $balance = null, ?string $token = null, ?string $units = null, ?string $reference = null): array { return array_filter([ 'success' => true, 'message' => $message, 'reference' => $reference, 'balance' => $balance, 'token' => $token, 'units' => $units, ], fn ($v) => $v !== null); } /** * A failed purchase. Return error_code "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. * * @return array */ public static function failed(string $message, ?string $errorCode = null): array { return array_filter([ 'success' => false, 'message' => $message, 'error_code' => $errorCode, ], fn ($v) => $v !== null); } /** * Reconciliation answer for /status. $status is one of completed, failed, * not_found, reversed or pending. * * @return array */ public static function status(string $status, ?int $balance = null, ?string $token = null, ?string $units = null): array { return array_filter([ 'status' => $status, 'balance' => $balance, 'token' => $token, 'units' => $units, ], fn ($v) => $v !== null); } /** * Your providers for a service. Keep labels recognizable ("Eko Electric * (EKEDC)") — chat channels match what the customer types against them. * * @param array $billers * @return array */ public static function billers(array $billers): array { return ['billers' => array_values(array_map( fn (array $b) => ['id' => (string) $b['id'], 'label' => (string) $b['label']], $billers, ))]; } /** * Fixed-price items. `amount` is whole naira — the price the customer pays. * * @param array $packages * @return array */ public static function packages(array $packages, ?string $network = null): array { return array_filter([ 'network' => $network, 'packages' => array_values(array_map(fn (array $p) => [ 'id' => (string) $p['id'], 'label' => (string) $p['label'], 'amount' => (int) $p['amount'], ], $packages)), ], fn ($v) => $v !== null); } /** * A recognized meter / smartcard / betting account. Pass min/max when the * provider has vend limits — VendStack enforces them before calling /vend. * * @return array */ public static function valid(string $name = '', ?string $address = null, ?int $minAmount = null, ?int $maxAmount = null): array { return array_filter([ 'valid' => true, 'name' => $name, 'address' => $address, 'min_amount' => $minAmount, 'max_amount' => $maxAmount, ], fn ($v) => $v !== null); } /** @return array */ public static function invalid(): array { return ['valid' => false]; } /** @return array */ public static function balance(int $balance): array { return ['balance' => $balance]; } /** * 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. * * @param array $accounts * @return array */ public static function accounts(array $accounts): array { return ['accounts' => array_values(array_map(fn (array $a) => [ 'bank' => (string) $a['bank'], 'account_number' => (string) $a['account_number'], 'account_name' => (string) $a['account_name'], ], $accounts))]; } /** * Whether a phone number already has an account. Be precise: only an * explicit false routes the customer to sign-up. * * @return array */ public static function accountExists(bool $exists): array { return ['exists' => $exists]; } /** @return array */ public static function registered(bool $success, string $message = ''): array { return array_filter(['success' => $success, 'message' => $message], fn ($v) => $v !== ''); } // ----------------------------------------------------------------------- // Internals // ----------------------------------------------------------------------- /** * Run a vend exactly once per reference. * * A network hiccup makes VendStack retry, and a retry carries the same * reference. We take a short lock so two copies can't run at the same * time, then cache the answer so the second one replays it rather than * charging the customer twice. * * @param Closure(): mixed $callback */ private static function once(VendStackRequest $request, Closure $callback): mixed { $reference = $request->reference(); if ($reference === null) { return $callback(); } $key = 'vendstack:vend:'.sha1($reference); if (($cached = Cache::get($key)) !== null) { return $cached; } $lock = Cache::lock($key.':lock', 120); if (! $lock->get()) { // The first copy is still running. Say so rather than double-charging; // VendStack reconciles this order through /status. return self::failed('This order is already being processed.', 'in_progress'); } try { $result = $callback(); if (is_array($result)) { Cache::put($key, $result, self::IDEMPOTENCY_TTL); } return $result; } finally { $lock->release(); } } /** * Your signing secret from the Merchant API card. * * Prefers config so it survives `config:cache`; the env() fallback is only * there for a first run before config/services.php has been updated. */ private static function secret(): string { return (string) (config('services.vendstack.secret') ?? env('VENDSTACK_SECRET', '')); } /** * The CSRF middleware to detach, so the endpoints work even when mounted * from routes/web.php. Named across Laravel versions; unknown names are * ignored by withoutMiddleware(). * * @return array */ private static function csrfMiddleware(): array { return [ // Laravel 13 renamed the base class; 11/12 use ValidateCsrfToken as // the base, and older apps have their own VerifyCsrfToken. The // router excludes a class AND its subclasses, so listing every base // covers all of them. 'Illuminate\Foundation\Http\Middleware\PreventRequestForgery', 'Illuminate\Foundation\Http\Middleware\ValidateCsrfToken', 'App\Http\Middleware\VerifyCsrfToken', ]; } /** * Log without ever writing a customer's PIN. * * @param array $context * @param array $payload */ private static function log(string $message, array $context, array $payload): void { unset($payload['pin']); Log::error($message, $context + ['payload' => $payload]); } } /** * One inbound VendStack request. Every field the contract can send is exposed * as a typed accessor; the ones irrelevant to the service are null. * * Lives in the same file as VendStack so the kit stays a single drop-in. */ final class VendStackRequest { /** @param array $payload */ public function __construct(private readonly array $payload) {} /** Unique per order and idempotent — the same reference is the same purchase. */ public function reference(): ?string { return $this->string('reference'); } /** One of airtime, data, cable, power, betting. Electricity is always "power". */ public function service(): ?string { return $this->string('service'); } /** The PRODUCT cost in whole naira — this is what you vend. */ public function amount(): int { return (int) ($this->payload['amount'] ?? 0); } /** Your own flat surcharge on this channel. Yours to keep. 0 when off. */ public function convenienceFee(): int { return (int) ($this->payload['convenience_fee'] ?? 0); } /** * amount + convenience_fee — 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. */ public function total(): int { return (int) ($this->payload['total'] ?? ($this->amount() + $this->convenienceFee())); } /** The customer's transaction PIN. Validate it, never store or log it. Null on scheduled runs. */ public function pin(): ?string { return $this->string('pin'); } /** The PAYER — the wallet owner. Validate the PIN against this number and debit it. */ public function customerMsisdn(): ?string { return $this->string('customer_msisdn'); } /** The RECIPIENT of airtime/data, which may differ from the payer. */ public function msisdn(): ?string { return $this->string('msisdn'); } public function network(): ?string { return $this->string('network'); } /** Your own biller id, as returned from /billers (e.g. "ekedc", "dstv"). */ public function biller(): ?string { return $this->string('biller'); } /** Smartcard / meter / betting account number. */ public function customerId(): ?string { return $this->string('customer_id'); } /** "prepaid" or "postpaid", on power only. */ public function meterType(): ?string { return $this->string('meter_type'); } /** Your own package id, as returned from /packages. */ public function packageId(): ?string { return $this->string('package_id'); } /** Free-text product label (e.g. "5GB") when a chat customer named a bundle without picking one. */ public function product(): ?string { return $this->string('product'); } /** Phone number, on /authenticate and /register. */ public function phone(): ?string { return $this->string('phone'); } /** Customer name, on /register. */ public function name(): ?string { return $this->string('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. */ public function isScheduled(): bool { return ($this->payload['authorization'] ?? null) !== null; } /** Anything else on the payload, by key. */ public function get(string $key, mixed $default = null): mixed { return $this->payload[$key] ?? $default; } /** @return array */ public function all(): array { return $this->payload; } private function string(string $key): ?string { $value = $this->payload[$key] ?? null; return $value === null || $value === '' ? null : (string) $value; } }