Skip to content
Partly builtas of vpay v0.4.1

What is vpay ​

vpay is meant to be a small, provider-agnostic payment gateway for Cameroon and Central Africa. Merchants integrate it the way they would integrate Stripe, with the same object model, the same idempotency semantics and the same webhook signature scheme. Underneath, it talks to mobile-money rails that behave nothing like cards. MTN MoMo and Orange Money are the first two adapters. Neither of them defines the architecture.

vpay has never taken a real payment

At v0.4.1 vpay is a scaffold. It compiles, lints clean and its tests pass, but it cannot take a payment. Do not deploy it. Every payment in its history has settled against a WireMock stub, with one exception: a single MTN sandbox charge on 2026-09-15. See What works today.

System overview: a merchant, the vpay-server serve and worker modes, Postgres, the two rails, the checkout page, the dashboard and outbound webhooks

Who it is for ​

ReaderWhat vpay offers them
A merchant developerA Stripe-shaped /v1 API (PaymentIntents, Checkout Sessions, Customers, Invoices, Events, Refunds), two merchant SDKs, a browser client and a hosted payment page
An operatorOne static binary in a FROM scratch image, configured entirely by YAML in git (ADR-0003), with a dashboard that observes and never administers
A contributorA repository that enforces its own honesty rules with build gates, so that what it says about itself stays checkable

Two rails, two very different payer journeys ​

Card payments have one shape. Mobile money has at least two, and vpay's core picks between them using a capability value (flow), never a rail name.

Push rail vs redirect rail: on MTN the payer enters a PIN on their handset and the intent goes to processing; on Orange the payer is redirected to Orange's page and the intent goes to requires_action

MTN MoMo (push)Orange Money (redirect)
How the payer actsEnters a PIN on their handsetIs redirected to Orange's hosted page
Intent status after confirmprocessingrequires_action
Who holds the payer's identifiervpay (it is an input to submit)The rail. vpay may never learn it
Can the payer act before vpay saves?YesNo

The last row matters more than the others. On a push rail the payer's phone starts buzzing as soon as vpay calls MTN, so vpay must save the reference before it makes the call. On a redirect rail the payer cannot do anything until vpay hands over a URL, so vpay must save Orange's token before it redirects. That is why crash safety has two enforcement points instead of one.

flowchart LR
    C["POST /v1/payment_intents/{id}/confirm"] --> F{"capability: flow"}
    F -->|push| P["persist reference,<br/>then call the rail"]
    P --> PR["processing<br/>payer enters PIN on handset"]
    F -->|redirect| R["call the rail,<br/>persist its token"]
    R --> RA["requires_action<br/>next_action.redirect_to_url"]
    PR --> W["vpay-server worker polls<br/>the authenticated status query"]
    RA --> W
    W --> T["succeeded, or back to<br/>requires_payment_method"]

A Stripe-shaped API, and where the comparison breaks ​

The object model, the idempotency semantics and the webhook signature are all copied from Stripe on purpose, so that a merchant's existing knowledge and code carry over. The comparison breaks in three places:

  1. Authentication. This is the big one. /v1 accepts no sk_live_/sk_test_ API key. Merchants authenticate with OAuth2 client_credentials plus private_key_jwt (RFC 7523). Each merchant is a statically registered client that holds its own private key, and vpay stores only the public half, in YAML (ADR-0010). The official stripe package can still reach vpay: @vaam-apps/vpay-sdk/stripe supplies a config.authenticator, so new Stripe("", { authenticator, host, port, protocol }) works with an empty key. See Stripe compatibility and Authentication.
  2. Idempotency is required. Every POST must carry an Idempotency-Key. Stripe makes the key optional.
  3. A retry is a new PaymentIntent. An intent can have only one charge, ever (One charge per intent).

The shape of the system ​

vpay ships as one binary, vpay-server, with several modes. With no subcommand it serves the API. vpay-server worker runs the job loop. The job loop polls rails, settles charges, delivers webhooks and escalates stuck payments. vpay-server staff add creates a dashboard account. Both of the long-running modes share one Postgres database.

flowchart LR
    M["Merchant backend<br/>(SDK or stripe-node)"] -->|"/v1 (bearer token)"| S
    B["Payer's browser<br/>(checkout page, stripe-js)"] -->|"/v1/browser (pk + client_secret)"| S
    ST["Staff (dashboard app)"] -->|"/dash/v1"| S
    RL["Rail"] -.->|"POST /provider/{code}/callback<br/>never called by MTN or Orange"| S
    S["vpay-server (serve)"] --> DB[("Postgres")]
    WK["vpay-server worker"] --> DB
    S -->|"submit on confirm"| RL
    WK -->|"authenticated status query"| RL
    WK -->|"signed webhooks"| M
SurfaceWho calls itToday
/v1A merchant's server, with a bearer token from POST /v1/oauth/tokenPayment intents, checkout sessions, customers, invoices, events, refunds, account holders
/v1/browserA payer's page, with a publishable key and the intent's client_secretBuilt, and walked by a real browser against stub rails
POST /provider/{code}/callbackA rail (callbacks are hints only)Proven against WireMock. MTN and Orange have never called it
/dash/v1The dashboard app's server, under a staff sessionStaff sign-in and read routes. None of the dashboard writes ADR-0008 describes is built

GET /v1/balance is not routed anywhere, and it answers an honest 404. A refund can be created, but a 201 does not mean money came back. Nothing in v0.4.1 settles a pending refund, and no rail has ever refunded anything.

The two web apps and the SDKs ​

  • frontends/apps/checkout is the payment page vpay serves, in hosted and embedded (iframe) modes. See Hosted checkout.
  • frontends/apps/dashboard is where staff sign in and read one merchant's payments. It observes and does not administer (ADR-0008). See Dashboard.
  • Merchant SDKs. sdks/rust (vpay-sdk) and sdks/nodejs (@vaam-apps/vpay-sdk) are held to one capability matrix. sdks/stripe-js (@vaam-apps/vpay-stripe-js) is the browser client for a payer's page. sdks/stripe-compat drives the official stripe package against a live stack, and sdks/flutter holds a Flutter checkout plugin. No test inside sdks/nodejs itself has ever talked to a vpay: every server in that package's own tests is a node:http stub. See SDKs.

The repository layout ​

flowchart TB
    subgraph BE["backends/"]
        CR["crates: vpay-core, -config, -db, -ledger,<br/>-provider, adapters, -api, -worker, -testkit"]
        AP["apps/vpay-server<br/>one musl binary, scratch image"]
        TS["tests: integration, conformance,<br/>webhook-receiver"]
    end
    subgraph FE["frontends/"]
        FA["apps: checkout, dashboard"]
        FP["packages: @vpay/tokens,<br/>@vpay/api-client, @vpay/config"]
        FT["tests/e2e (Cypress)"]
    end
    subgraph SD["sdks/"]
        SR["rust, nodejs"]
        SJ["stripe-js, stripe-compat"]
        SF["flutter"]
    end
    EX["examples/<br/>merchant-demo, shop, checkout-browser, ..."]
    SC["schemas/vpay.cstack"]
    DP["deploy/helm/vpay<br/>never applied to a cluster"]
    XT[".xtask/<br/>repo automation and verify gates"]
    DC["docs/<br/>adr, flows, runbooks, status.md, ..."]
    CR --> AP
    SR -->|"/v1"| AP
    FA -->|"/v1/browser, /dash/v1"| AP

The backend is Rust (edition 2024) with axum, sqlx and rustls only. It builds static musl binaries into FROM scratch images with mimalloc. The frontend is Next.js 15 and React 19 in strict TypeScript. examples/shop runs on Next 16.

Two rules the repository enforces on itself ​

Both rules are wired into just verify and CI.

  1. No test doubles in shipping processes. No mock, fake or stub may be reachable from vpay-server in any of its modes. A stub rail is a WireMock host in configuration, reached over HTTP exactly as a real rail would be (ADR-0006).
  2. Never claim a feature is done when it is not. Code that has not been written returns ProviderError::NotImplemented and never fakes a success. Every such path must be declared in docs/status.md, and cargo xtask verify-status fails in both directions if the code and the page disagree.

Rails also stay behind the port. Code outside an adapter crate that branches on a provider code, as in if provider == "mtn_momo", is a defect (ADR-0002, Provider port).

Status in v0.4.1 ​

PartStatusEvidence
End-to-end payment against stub railsPartly builtjust demo walks six payments on both rails. Every rail in those runs is a WireMock container
MTN MoMo charge pathPartly builtOne EUR sandbox charge settled on 2026-09-15. The payer was a test number, and no money moved
Orange MoneyWritten, never run against the real railWire calls proven against WireMock only. Orange has never been called
RefundsWritten, never run against the real railRoutes exist. Nothing settles a pending refund, and no rail has ever refunded anything
A deploymentNot builtThe Helm chart renders and is schema-validated. No cluster has ever run vpay

See What works today for the full picture, and docs/status.md for the record.

Go deeper ​

  • README.md: the project's own front page, including the /v1 route table and the layout
  • AGENTS.md: the rules, the standards and the architecture rules every change follows
  • Payment lifecycle flow: the two flow shapes and every state
  • ADR-0002 and ADR-0010: the port and merchant authentication
  • Agents orienting in this repository should load the vpay skill first. It routes to the rest.

Verified against vpay v0.4.1 (2026-09-22). vpay is a scaffold — do not deploy it.