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'); * } * * // Debit TOTAL (product + your convenience fee), vend AMOUNT. * 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->on('balance', fn (VendStackRequest $r) => VendStack::balance( * my_balance_of($r->msisdn()) * )); * * $vendstack->serve(); // verifies, dispatches, echoes JSON, exits * * Endpoints you can register: billers, packages, validate, vend, status, * balance, accounts, authenticate, register. * * --------------------------------------------------------------------------- * 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 a * file lock blocks two concurrent copies of the same reference. * - PINs are never written to logs. * - 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. */ 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; /** @var array */ private array $handlers = []; private string $storageDir; /** * @param string $secret signing secret from the Merchant API card * @param string|null $storageDir where idempotency records live; defaults to * the system temp dir. Point this at durable, * writable storage shared by all your web * servers if you run more than one. */ public function __construct(private readonly string $secret, ?string $storageDir = null) { $this->storageDir = rtrim($storageDir ?? sys_get_temp_dir().'/vendstack', '/'); } /** * Register the logic for one endpoint. The callable receives a * VendStackRequest and returns an array (use the builders below). */ public function on(string $endpoint, callable $handler): self { $this->handlers[$endpoint] = $handler; return $this; } /** * Handle the current request end to end and exit: verify the signature, * dispatch, and echo JSON with the right status code. * * Pass $endpoint explicitly if your router already knows it; otherwise the * last path segment of the URL is used (/vendstack/vend -> "vend"). */ public function serve(?string $endpoint = null): void { $endpoint ??= $this->endpointFromUrl(); [$status, $body] = $this->respond($endpoint, $this->rawBody(), $this->headers()); http_response_code($status); header('Content-Type: application/json'); echo json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); exit; } /** * The same work as serve(), but returns [statusCode, responseArray] instead * of writing to the output buffer. Use this to mount VendStack inside an * existing framework, and to unit-test your handlers. * * @param array $headers * @return array{0: int, 1: array} */ public function respond(string $endpoint, string $body, array $headers): array { $headers = array_change_key_case($headers); if (! in_array($endpoint, self::ENDPOINTS, true)) { return [404, ['error' => ['message' => 'Unknown endpoint.']]]; } if ($this->secret === '') { // Say so plainly. Otherwise every request 401s and looks like a // signing bug rather than a missing setting. return [500, ['error' => ['message' => 'VendStack signing secret is not configured.']]]; } $verified = self::verify( $endpoint, $headers['x-vendstack-timestamp'] ?? null, $headers['x-vendstack-signature'] ?? null, $body, $this->secret, ); if (! $verified) { return [401, ['error' => ['message' => 'Invalid signature.']]]; } $payload = json_decode($body, true); if (! is_array($payload)) { return [400, ['error' => ['message' => 'Malformed JSON body.']]]; } if (! isset($this->handlers[$endpoint])) { // Not implemented is a normal, safe answer: VendStack degrades the // matching feature rather than failing the customer's purchase. return [404, ['error' => ['message' => "The {$endpoint} endpoint is not implemented."]]]; } $request = new VendStackRequest($payload); $handler = $this->handlers[$endpoint]; try { $result = $endpoint === 'vend' ? $this->once($request, static fn () => $handler($request)) : $handler($request); } catch (Throwable $e) { $this->log('vendstack.'.$endpoint.' failed: '.$e->getMessage(), $payload); return [500, ['error' => ['message' => 'Could not complete the request. Please try again.']]]; } return [200, is_array($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): bool { 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. * * if (! VendStack::verifyWebhook($ts, $sig, file_get_contents('php://input'), $secret)) { * http_response_code(401); * exit; * } */ public static function verifyWebhook(?string $timestamp, ?string $signature, string $body, string $secret): bool { 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.'.'.$body, $secret), $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. * * @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, ], static 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, ], static 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, ], static 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( static 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(static fn (array $p) => [ 'id' => (string) $p['id'], 'label' => (string) $p['label'], 'amount' => (int) $p['amount'], ], $packages)), ], static 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, ], static 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(static 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], static 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 an exclusive file lock so two copies can't run at the * same time, then store the answer so the second one replays it rather * than charging the customer twice. * * @return array */ private function once(VendStackRequest $request, callable $callback): array { $reference = $request->reference(); if ($reference === null) { return (array) $callback(); } if (! is_dir($this->storageDir) && ! @mkdir($this->storageDir, 0770, true) && ! is_dir($this->storageDir)) { // Can't persist — better to vend than to refuse, but say so loudly: // without this directory a retried order can be charged twice. $this->log('vendstack: idempotency storage '.$this->storageDir.' is not writable', []); return (array) $callback(); } $file = $this->storageDir.'/'.sha1($reference).'.json'; if (($replay = $this->readRecord($file)) !== null) { return $replay; } $lock = fopen($file.'.lock', 'c'); if ($lock === false || ! flock($lock, LOCK_EX | LOCK_NB)) { if (is_resource($lock)) { fclose($lock); } // 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 { // Re-check under the lock: the first copy may have finished between // our read above and taking the lock. if (($replay = $this->readRecord($file)) !== null) { return $replay; } $result = (array) $callback(); file_put_contents($file, json_encode(['at' => time(), 'response' => $result]), LOCK_EX); return $result; } finally { flock($lock, LOCK_UN); fclose($lock); } } /** * The stored response for a reference, or null if there isn't a live one. * * @return array|null */ private function readRecord(string $file): ?array { if (! is_file($file)) { return null; } $record = json_decode((string) file_get_contents($file), true); if (! is_array($record) || ! is_array($record['response'] ?? null)) { return null; } if (time() - (int) ($record['at'] ?? 0) > self::IDEMPOTENCY_TTL) { @unlink($file); return null; } return $record['response']; } private function endpointFromUrl(): string { $path = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH); return basename(is_string($path) ? $path : ''); } private function rawBody(): string { return (string) file_get_contents('php://input'); } /** @return array */ private function headers(): array { if (function_exists('getallheaders')) { return (array) getallheaders(); } $headers = []; foreach ($_SERVER as $key => $value) { if (str_starts_with((string) $key, 'HTTP_')) { $headers[str_replace('_', '-', substr((string) $key, 5))] = (string) $value; } } return $headers; } /** * Log without ever writing a customer's PIN. * * @param array $payload */ private function log(string $message, array $payload): void { unset($payload['pin']); error_log($message.($payload === [] ? '' : ' '.json_encode($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. */ 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; } }