// VendStack — ASP.NET Core Merchant API integration kit (single file, no NuGet // packages beyond the framework). // // 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 into your project (e.g. Integrations/VendStack.cs). // // 2. Put your signing secret (Dashboard -> Integrations -> Merchant API) in // appsettings.json or the environment: // // { "VendStack": { "Secret": "whsec_xxxxxxxxxxxxxxxx" } } // // 3. Map the endpoints in Program.cs, and give VendStack the base URL // https://your-domain.com/vendstack on the Merchant API card: // // using VendStack; // // var builder = WebApplication.CreateBuilder(args); // builder.Services.AddScoped(); // // var app = builder.Build(); // app.MapVendStack(); // defaults to /vendstack // app.Run(); // // --------------------------------------------------------------------------- // YOUR HANDLER — implement only what your products need // --------------------------------------------------------------------------- // VendAsync is required. Everything else is virtual and defaults to "not // implemented", which VendStack degrades safely rather than failing a purchase. // // public sealed class MyVendStackHandler(WalletService wallets, IVendor vendor) : VendStackHandler // { // public override async Task 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"); // } // // // Debit TOTAL (product + your convenience fee), vend AMOUNT. // 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); // } // // public override async Task BalanceAsync(VendStackRequest r) // => VendStackReply.Balance(await wallets.BalanceOfAsync(r.Msisdn)); // } // // --------------------------------------------------------------------------- // 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 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 #nullable enable using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace VendStack; /// Mounts the VendStack endpoints on an ASP.NET Core app. public static class VendStackExtensions { /// The endpoints VendStack may call. public static readonly string[] Endpoints = { "billers", "packages", "validate", "vend", "status", "balance", "accounts", "authenticate", "register", }; /// Reject a request signed longer ago than this. public static readonly TimeSpan TimestampTolerance = TimeSpan.FromMinutes(5); /// How long a completed /vend response is replayed for a repeated reference. public static readonly TimeSpan IdempotencyTtl = TimeSpan.FromHours(24); /// /// Publish POST {prefix}/{billers,packages,validate,vend,status,balance, /// accounts,authenticate,register}, each verified and dispatched to /// . /// /// /// Your signing secret. Leave null to read configuration key "VendStack:Secret". /// public static IEndpointRouteBuilder MapVendStack( this IEndpointRouteBuilder endpoints, string prefix = "/vendstack", string? secret = null, IVendStackStore? store = null) where THandler : VendStackHandler { secret ??= endpoints.ServiceProvider.GetService()?["VendStack:Secret"]; if (string.IsNullOrEmpty(secret)) { throw new InvalidOperationException( "VendStack: a signing secret is required. Set VendStack:Secret in configuration " + "(Dashboard -> Integrations -> Merchant API)."); } store ??= VendStackMemoryStore.Shared; foreach (var endpoint in Endpoints) { endpoints.MapPost($"{prefix.TrimEnd('/')}/{endpoint}", async (HttpContext context) => await HandleAsync(context, endpoint, secret, store)); } return endpoints; } private static async Task HandleAsync( HttpContext context, string endpoint, string secret, IVendStackStore store) where THandler : VendStackHandler { using var reader = new StreamReader(context.Request.Body, Encoding.UTF8); var body = await reader.ReadToEndAsync(); var verified = VendStackSignature.Verify( endpoint, context.Request.Headers["X-VendStack-Timestamp"], context.Request.Headers["X-VendStack-Signature"], body, secret); if (!verified) { return Results.Json(Error("Invalid signature."), statusCode: 401); } VendStackRequest request; try { request = VendStackRequest.Parse(body); } catch (JsonException) { return Results.Json(Error("Malformed JSON body."), statusCode: 400); } var handler = context.RequestServices.GetService() ?? ActivatorUtilities.CreateInstance(context.RequestServices); try { var result = endpoint == "vend" ? await OnceAsync(store, request, () => handler.VendAsync(request)) : await DispatchAsync(handler, endpoint, request); // A null result means the merchant has not implemented this endpoint. // That is a normal, safe answer: VendStack degrades the matching // feature rather than failing the customer's purchase. return result is null ? Results.Json(Error($"The {endpoint} endpoint is not implemented."), statusCode: 404) : Results.Json(result); } catch (Exception exception) { // Logged without the payload, so a PIN can never reach the log. context.RequestServices.GetService()? .CreateLogger("VendStack") .LogError(exception, "vendstack.{Endpoint} failed for reference {Reference}", endpoint, request.Reference); return Results.Json(Error("Could not complete the request. Please try again."), statusCode: 500); } } private static Task DispatchAsync(VendStackHandler handler, string endpoint, VendStackRequest request) => endpoint switch { "billers" => handler.BillersAsync(request), "packages" => handler.PackagesAsync(request), "validate" => handler.ValidateAsync(request), "status" => handler.StatusAsync(request), "balance" => handler.BalanceAsync(request), "accounts" => handler.AccountsAsync(request), "authenticate" => handler.AuthenticateAsync(request), "register" => handler.RegisterAsync(request), _ => Task.FromResult(null), }; /// /// 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. /// private static async Task OnceAsync( IVendStackStore store, VendStackRequest request, Func> callback) { if (string.IsNullOrEmpty(request.Reference)) { return await callback(); } var key = $"vendstack:vend:{request.Reference}"; var cached = await store.GetAsync(key); if (cached is not null) { return cached; } if (!VendStackMemoryStore.InFlight.TryAdd(key, 0)) { // The first copy is still running. Say so rather than double-charging; // VendStack reconciles this order through /status. return VendStackReply.Failed("This order is already being processed.", "in_progress"); } try { var result = await callback(); if (result is IDictionary reply) { await store.SetAsync(key, reply, IdempotencyTtl); } return result; } finally { VendStackMemoryStore.InFlight.TryRemove(key, out _); } } private static Dictionary Error(string message) => new() { ["error"] = new Dictionary { ["message"] = message } }; } /// /// Your vending logic. Override only the endpoints your products need — /// airtime-only merchants override just . /// public abstract class VendStackHandler { /// Complete the purchase: validate the PIN, debit Total, vend Amount. public abstract Task VendAsync(VendStackRequest request); /// Your providers for a service (cable / power / betting). public virtual Task BillersAsync(VendStackRequest request) => Task.FromResult(null); /// Fixed-price items: data bundles by phone number, cable bouquets by biller. public virtual Task PackagesAsync(VendStackRequest request) => Task.FromResult(null); /// Confirm a smartcard / meter / betting id and return the account holder. public virtual Task ValidateAsync(VendStackRequest request) => Task.FromResult(null); /// Reconcile a timed-out purchase by its reference. Recommended. public virtual Task StatusAsync(VendStackRequest request) => Task.FromResult(null); /// A customer's wallet balance, shown before they confirm. public virtual Task BalanceAsync(VendStackRequest request) => Task.FromResult(null); /// Bank accounts the customer can transfer to in order to fund their wallet. public virtual Task AccountsAsync(VendStackRequest request) => Task.FromResult(null); /// Whether a phone number already has an account. public virtual Task AuthenticateAsync(VendStackRequest request) => Task.FromResult(null); /// Create an account for a new customer signing up in chat. public virtual Task RegisterAsync(VendStackRequest request) => Task.FromResult(null); } /// /// One inbound VendStack request. Every field the contract can send is exposed /// as a property; the ones irrelevant to the service are null. /// public sealed class VendStackRequest { /// Unique per order and idempotent — the same reference is the same purchase. public string? Reference { get; private init; } /// One of airtime, data, cable, power, betting. Electricity is always "power". public string? Service { get; private init; } /// The PRODUCT cost in whole naira — this is what you vend. public int Amount { get; private init; } /// Your own flat surcharge on this channel. Yours to keep. 0 when off. public int ConvenienceFee { get; private init; } /// /// 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. /// public int Total { get; private init; } /// The customer's transaction PIN. Validate it, never store or log it. Null on scheduled runs. public string? Pin { get; private init; } /// The PAYER — the wallet owner. Validate the PIN against this number and debit it. public string? CustomerMsisdn { get; private init; } /// The RECIPIENT of airtime/data, which may differ from the payer. public string? Msisdn { get; private init; } public string? Network { get; private init; } /// Your own biller id, as returned from /billers (e.g. "ekedc", "dstv"). public string? Biller { get; private init; } /// Smartcard / meter / betting account number. public string? CustomerId { get; private init; } /// "prepaid" or "postpaid", on power only. public string? MeterType { get; private init; } /// Your own package id, as returned from /packages. public string? PackageId { get; private init; } /// Free-text product label (e.g. "5GB") when a chat customer named a bundle without picking one. public string? Product { get; private init; } /// Phone number, on /authenticate and /register. public string? Phone { get; private init; } /// Customer name, on /register. public string? Name { get; private init; } /// /// 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 bool IsScheduled { get; private init; } /// The raw payload, for anything not surfaced above. public JsonElement Raw { get; private init; } public static VendStackRequest Parse(string body) { using var document = JsonDocument.Parse(body); var root = document.RootElement; var amount = Int(root, "amount"); var fee = Int(root, "convenience_fee"); return new VendStackRequest { Reference = Str(root, "reference"), Service = Str(root, "service"), Amount = amount, ConvenienceFee = fee, Total = root.TryGetProperty("total", out _) ? Int(root, "total") : amount + fee, Pin = Str(root, "pin"), CustomerMsisdn = Str(root, "customer_msisdn"), Msisdn = Str(root, "msisdn"), Network = Str(root, "network"), Biller = Str(root, "biller"), CustomerId = Str(root, "customer_id"), MeterType = Str(root, "meter_type"), PackageId = Str(root, "package_id"), Product = Str(root, "product"), Phone = Str(root, "phone"), Name = Str(root, "name"), IsScheduled = Str(root, "authorization") is not null, Raw = root.Clone(), }; } private static string? Str(JsonElement root, string key) => root.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String ? (string.IsNullOrEmpty(value.GetString()) ? null : value.GetString()) : null; private static int Int(JsonElement root, string key) { if (!root.TryGetProperty(key, out var value)) { return 0; } return value.ValueKind switch { JsonValueKind.Number => value.TryGetInt32(out var number) ? number : (int)value.GetDouble(), JsonValueKind.String => int.TryParse(value.GetString(), out var parsed) ? parsed : 0, _ => 0, }; } } /// Return these from your handler methods. public static class VendStackReply { /// /// A successful purchase. balance (wallet after), token/units (prepaid /// electricity) are optional and shown to the customer when present. /// public static IDictionary Done( string message = "Successful", int? balance = null, string? token = null, string? units = null, string? reference = null) => Compact(new Dictionary { ["success"] = true, ["message"] = message, ["reference"] = reference, ["balance"] = balance, ["token"] = token, ["units"] = 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. /// public static IDictionary Failed(string message, string? errorCode = null) => Compact(new Dictionary { ["success"] = false, ["message"] = message, ["error_code"] = errorCode, }); /// /// Reconciliation answer for /status. status is one of completed, failed, /// not_found, reversed or pending. /// public static IDictionary Status( string status, int? balance = null, string? token = null, string? units = null) => Compact(new Dictionary { ["status"] = status, ["balance"] = balance, ["token"] = token, ["units"] = units, }); /// /// Your providers for a service. Keep labels recognizable ("Eko Electric /// (EKEDC)") — chat channels match what the customer types against them. /// public static IDictionary Billers(IEnumerable<(string Id, string Label)> billers) => new Dictionary { ["billers"] = billers .Select(b => new Dictionary { ["id"] = b.Id, ["label"] = b.Label }) .ToList(), }; /// Fixed-price items. amount is whole naira — the price the customer pays. public static IDictionary Packages( IEnumerable<(string Id, string Label, int Amount)> packages, string? network = null) => Compact(new Dictionary { ["network"] = network, ["packages"] = packages .Select(p => new Dictionary { ["id"] = p.Id, ["label"] = p.Label, ["amount"] = p.Amount, }) .ToList(), }); /// /// A recognized meter / smartcard / betting account. Pass minAmount/maxAmount /// when the provider has vend limits — VendStack enforces them before /vend. /// public static IDictionary Valid( string name = "", string? address = null, int? minAmount = null, int? maxAmount = null) => Compact(new Dictionary { ["valid"] = true, ["name"] = name, ["address"] = address, ["min_amount"] = minAmount, ["max_amount"] = maxAmount, }); public static IDictionary Invalid() => new Dictionary { ["valid"] = false }; public static IDictionary Balance(int balance) => new Dictionary { ["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. /// public static IDictionary Accounts( IEnumerable<(string Bank, string AccountNumber, string AccountName)> accounts) => new Dictionary { ["accounts"] = accounts .Select(a => new Dictionary { ["bank"] = a.Bank, ["account_number"] = a.AccountNumber, ["account_name"] = a.AccountName, }) .ToList(), }; /// /// Whether a phone number already has an account. Be precise: only an /// explicit false routes the customer to sign-up. /// public static IDictionary AccountExists(bool exists) => new Dictionary { ["exists"] = exists }; public static IDictionary Registered(bool success, string? message = null) => Compact(new Dictionary { ["success"] = success, ["message"] = message }); /// Drop null values so optional fields are simply absent from the JSON. private static IDictionary Compact(Dictionary values) => values.Where(pair => pair.Value is not null).ToDictionary(pair => pair.Key, pair => pair.Value); } /// HMAC verification for requests and webhooks from VendStack. public static class VendStackSignature { /// /// 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 bool Verify(string endpoint, string? timestamp, string? signature, string body, string secret) => Matches($"{timestamp}.POST./{endpoint.TrimStart('/')}.{body}", timestamp, signature, secret); /// /// 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. /// /// /// app.MapPost("/vendstack/webhook", async (HttpContext context) => /// { /// using var reader = new StreamReader(context.Request.Body); /// var body = await reader.ReadToEndAsync(); /// /// if (!VendStackSignature.VerifyWebhook( /// context.Request.Headers["X-VendStack-Timestamp"], /// context.Request.Headers["X-VendStack-Signature"], /// body, secret)) /// { /// return Results.Unauthorized(); /// } /// /// await orders.ReconcileAsync(body); /// return Results.NoContent(); /// }); /// /// public static bool VerifyWebhook(string? timestamp, string? signature, string body, string secret) => Matches($"{timestamp}.{body}", timestamp, signature, secret); private static bool Matches(string signed, string? timestamp, string? signature, string secret) { if (string.IsNullOrEmpty(secret) || string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(signature)) { return false; } if (!long.TryParse(timestamp, out var sentAt)) { return false; } var age = Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - sentAt); if (age > VendStackExtensions.TimestampTolerance.TotalSeconds) { return false; // stale or replayed } using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var expected = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(signed))).ToLowerInvariant(); return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature)); } } /// Where answered vends are remembered so a retry can't charge twice. public interface IVendStackStore { Task?> GetAsync(string key); Task SetAsync(string key, IDictionary value, TimeSpan ttl); } /// /// The default idempotency store: a per-process dictionary 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 /// backed by Redis or your database to MapVendStack. /// public sealed class VendStackMemoryStore : IVendStackStore { public static readonly VendStackMemoryStore Shared = new(); /// References currently being vended, so a retry can't run alongside the original. internal static readonly ConcurrentDictionary InFlight = new(); private readonly ConcurrentDictionary Value, DateTimeOffset ExpiresAt)> entries = new(); public Task?> GetAsync(string key) { if (!entries.TryGetValue(key, out var entry)) { return Task.FromResult?>(null); } if (DateTimeOffset.UtcNow > entry.ExpiresAt) { entries.TryRemove(key, out _); return Task.FromResult?>(null); } return Task.FromResult?>(entry.Value); } public Task SetAsync(string key, IDictionary value, TimeSpan ttl) { entries[key] = (value, DateTimeOffset.UtcNow.Add(ttl)); return Task.CompletedTask; } }