Why the code in backends/crates/vpay-api looks the way it does. The crate's
own doc comments say what each item is and link here; this page carries the
reasoning, the ports, the measurements and the history that a reader needs
once — not on every cargo doc build.
Tier: an ADR records a decision, a flow describes a process, and a reference page like this one explains why a particular piece of code is shaped the way it is.
- The router
- The rest of this reference — a page per
surface, under
vpay-api/
This list was a full table of contents of a 1 566-line page until 2026-09-11.
One of its entries had also been wrong since 2026-09-07: it offered "Why the
client_id check is not redundant with the audience" as an anchor, and
ADR-0017 had renamed that heading to
"What identifies the credential (ADR-0017), and what it replaced". An in-page
anchor that stops resolving is a broken link verify-links cannot report,
because it strips the fragment before it checks the path — which is why the
entries below name pages rather than headings.
vpay_api::router is the one place the process's HTTP surface is assembled.
Three groups, and which group a path falls into is the whole security boundary of this process:
| Path | Auth | Why |
|---|---|---|
GET /healthz |
none | A probe must answer before anything is configured, and it reveals only whether Postgres is reachable. It is the readiness probe; liveness is /livez on the observability port. |
POST /v1/oauth/token |
none | The credential is the request body (RFC 7523 client_assertion). Requiring a bearer token to get a bearer token is circular. |
GET /v1/oauth/.well-known/openid-configuration |
none | How a client that has never spoken to vpay finds the token endpoint. |
GET /v1/oauth/jwks.json |
none | How a verifier that has never spoken to vpay learns the public keys. Same circularity. |
anything else under /v1/oauth |
none | The OP subtree is public by design; its own .fallback(not_found) answers the honest 404 rather than letting the path escape to the outer router. |
GET /v1/browser/payment_intents/{id} |
none | A payer's browser has no merchant credential. The payment intent's own client_secret is what authorises it — see vpay_api::browser. |
POST /v1/browser/payment_intents/{id}/confirm |
none | The same. |
anything else under /v1/browser |
none | Its own .fallback(not_found), for the OP nest's reason: without one the path would match /v1/{*rest} and answer 401 to a caller that can never hold a token. |
everything else under /v1 |
AuthenticatedMerchant |
The merchant API. |
GET /dash/v1/payment_intents |
require_dashboard_token |
The staff dashboard's payments list. Mounted only when dashboard_client is configured; otherwise the path falls through to the outer 404. See the dashboard surface. |
GET /dash/v1/payment_intents/{id} |
require_dashboard_token |
The payment detail: the intent, its charge, its refunds, its event timeline. |
anything else under /dash/v1 |
require_dashboard_token |
Its own .fallback(not_found), for the OP nest's reason — and every non-read method is refused by the boundary before the router matches. |
POST /provider/{code}/callback |
none, and none is possible | A payment rail telling us something happened. Neither MTN nor Orange signs a callback or sends a shared secret, so there is no credential to check — which is exactly why the handler may not write charge or intent state. See the rail callback route. |
anything else under /provider |
none | Its own .fallback(not_found), for the OP nest's reason. |
| anything else | none | The honest 404. |
That table describes a process with deployment.surfaces absent — every
surface, which is what every deployment predating ADR-0022 has. When the key
is set, router mounts a subset, and the rows fall into three groups
(ADR-0022 § "Where /v1/oauth goes" and § 2):
- business only —
/v1,/v1/browser,/provider, andPOST /v1/oauth/token./v1/oauth/tokenmints the merchant credential; ADR-0017's staff grant is a different endpoint (/dash/v1/oauth/token), so asurfaces: [management]process does not serve it. - management only —
/dash/v1and thestaffsign-in routes, which were already conditional ondashboard_clientbefore ADR-0022 and are now conditional on both. - every surface —
/healthz,GET /v1/oauth/jwks.jsonandGET /v1/oauth/.well-known/openid-configuration. The discovery pair mints nothing, and the management tier's own token validation reads JWKS over HTTP: making it fetch that from the business tier would recreate the coupling ADR-0022 removes.
An unmounted path is the outer honest 404, never a refusal — a path a
deployment does not serve is not a permissions question.
tests::surfaces in vpay-api/src/lib.rs pins all three groups.
/livez and /metrics are not in that table and are not served by this
router at all. They belong to vpay_api::observability, on
--observability-bind (default 0.0.0.0:9090), because /metrics names every
rail, route pattern and error code this deployment has and must not be
reachable from whatever fronts the traffic port. The chart's NetworkPolicy
encodes that, and it can only do so because the two are different ports.
/v1/browser is the only nest carrying a CorsLayer; the merchant /v1 nest
and the /provider callback nest deliberately carry none — the first because
nothing legitimate calls it from a browser and a permissive header there would
invite a merchant to put a bearer token in a page, the second because its
caller is a rail's own backend and there is no origin to allow.
/provider sits outside /v1 on purpose. It is not part of the merchant
API — no SDK calls it and it carries no resource version — and mounting it
inside the one prefix whose whole boundary is "everything here needs a bearer
token" would put an unauthenticated route inside it. The path is also not a
free choice: vpay_config::ProviderHost::effective_callback_url has derived
{public_base_url}/provider/{code}/callback since Step 3, and both adapters
have been sending it to their rails ever since, so this is the route that
address was always pointing at.
The /v1 nest mounts v1::V1_ROUTES and a 404 fallback for everything else,
which is the production behaviour and not a placeholder: /v1/payment_intents,
/v1/events, /v1/checkout/sessions and — since 2026-09-05, issue #45 —
GET /v1/refunds/{id} are real, and
Corrected 2026-09-20: all five refund routes have been mounted since
2026-09-16 (RFC-0003 § 2) —
POST /v1/refunds and /v1/balance are
not implemented and are therefore not routed (status.md). The
refund pair is the one place a read is mounted without its create, and
v1::refunds' own module doc carries the argument: creating a refund needs
ProviderAdapter::refund, which no adapter implements, while reading one is
the authoritative read every other money movement on this surface has.mount: || post(refunds::create).get(refunds::list) at
backends/crates/vpay-api/src/v1/mod.rs:289 and
mount: || post(refunds::cancel) at :309 — so v1::refunds reaches
Refunds::create, cancel and both reads. /v1/balance is still
deliberately unrouted (mod.rs:185); that half of the original claim held.
What remains unbuilt is settlement: Settlement::apply_refund_succeeded
has no shipping caller, so no refund a merchant creates today is ever marked
settled, regardless of what the rail does. See status.md. The
boundary is observable in three answers —
GET /v1/payment_intents/pi_xwith no bearer token → 401, theApiError::Authenvelope;- the same request with a valid merchant token, for an id this merchant has no
intent under → 404, the
resource_missingenvelope; GET /v1/balancewith a valid token → 404, theunknown_routeenvelope.
— which is exactly what a merchant integrating against this deployment should
get. Inventing a /v1/balance so the third answer could be a 200 is the
failure mode CLAUDE.md names first.
The authentication layer is require_merchant_token via from_fn_with_state
(Step 2's D3 — that function's docs say why it is not
from_extractor_with_state), mounted with Router::layer on the nested router
so that it wraps that router's fallback too. route_layer is the wrong tool and
axum says so: it does not apply to a fallback by design, so an unmatched
/v1/... path would answer an unauthenticated 404 and tell an anonymous
caller which /v1 resources exist. When this nest had no routes at all, axum
refused that spelling outright ("Adding a route_layer before any routes is a
no-op"); now that it has routes, the swap would compile and be silently wrong,
which is why the choice is written down rather than left to the compiler —
an_unauthenticated_v1_request_is_401_not_404 is what actually catches it.
A path under /v1/oauth that matches no OP route (say /v1/oauth/authorize,
which vpay does not serve) answers an unauthenticated 404 — and does so from
the OP router's own .fallback(not_found). That outcome is intended: the
whole /v1/oauth subtree is public by design, so a 404 there leaks nothing a
merchant could not learn from the discovery document.
The fallback is load-bearing, not decoration, and the code's comment used to
say the opposite. It previously claimed that an unmatched /v1/oauth/...
path "falls through to the outer router's fallback and answers an
unauthenticated 404". Measured, it did not: with no fallback on the OP router,
axum flattens that nest's three routes into the outer path table and registers
no /v1/oauth/{*rest} entry at all, so GET /v1/oauth/not_a_route matched
/v1/{*rest} — the authenticated nest — and answered 401. Removing the
.fallback(not_found) reproduces it, and the_oauth_nest_answers_its_own_404
fails with left: 401, right: 404.
A 401 there is the wrong answer twice over: it tells an integrator who mistyped
an OP path to present a bearer token, on the one subtree whose entire purpose is
handing out bearer tokens to callers that do not have one yet — and it made
"which router serves this path" depend on an axum flattening detail rather than
on anything written down. With the fallback the OP router is closed over its own
prefix: every /v1/oauth/... path is served by the OP router, unmatched ones
included, and that is a property a test checks rather than an accident of
registration order.
A known OP path with the wrong method — GET /v1/oauth/token — gets axum's
own bare 405, not this crate's envelope. Left as-is deliberately: 405 is the
correct status, and turning it into the 404 envelope would tell an integrator
the path does not exist when it does. The gap is that its body is empty rather
than the Stripe envelope; that is worth fixing when a method_not_allowed
renderer exists for the whole surface, not one route at a time.
ServiceBuilder applies layers outside-in, so the list below is the order a
request traverses them, and the reverse of the order a response does. All five
are load-bearing in that order:
discard_unusable_request_id— removes a caller'sx-request-idunless it is short and plain enough to carry (is_usable_request_id). It must be first, and above the minting layer specifically: step 2 only mints when the header is absent, so this step's removal is exactly what causes a fresh id to be minted for a caller whose own id was not usable.SetRequestIdLayer— mints anx-request-id(a v4 UUID, viaMakeRequestUuid) on the request, unless the caller already sent one that step 1 kept, in which case theirs is kept. Everything below reads the header it sets.mirror_request_id_header— copies the id step 2 settled on onto the response a second time, asrequest-id, because that is the only spelling stripe-node reads. Below step 2 because it reads the request header step 2 guarantees is there; above step 5 only because nothing makes the order between them matter — both take the value from the request, so neither can observe the other.TraceLayer— opensmake_request_spanaround the handler, so the id is on the span before any handler, extractor or error renderer runs, and every event they emit inherits it.PropagateRequestIdLayer— innermost, so it sees the id step 2 set and is the first layer to touch the response on the way out; it copies the request's id onto the response, which is what makesCategory::Internal's "Contact support with the request id" a promise a merchant can act on.
Step 1 is axum::middleware::from_fn rather than
tower::util::MapRequestLayer: MapRequestLayer sits behind tower's util
feature, which the workspace pin (tower = "0.5", no feature list) does not
enable — it is on today only through feature unification from an unrelated
transitive dependency, so using it would make this stack compile by accident.
from_fn needs no feature axum does not already have.
The stack is mounted on the outermost router, so it wraps every group above —
including the 401 an unauthenticated /v1 request gets, which is the response
most likely to be the one a confused integrator is holding, and which therefore
needs a request id on it more than any other.
docs/reference/vpay-api.md was 1 566 lines until 2026-09-11. It is the router
and the overview now; each surface has its own page below, moved verbatim.
Nothing was summarised away and no dated measurement or correction was dropped.
| Surface | Page |
|---|---|
The merchant OP (op/) |
vpay-api/merchant-op.md |
Resource-server JWT validation (resource_auth.rs) |
vpay-api/resource-auth.md |
The dashboard surface (dash/) |
vpay-api/dashboard-surface.md |
The JWKS cache (jwks_cache.rs) |
vpay-api/jwks-cache.md |
The form decoder (form.rs) |
vpay-api/form-decoder.md |
| The confirm path, and the return trip | vpay-api/confirm-path.md |
| Checkout Sessions | vpay-api/checkout-sessions.md |
| The account-holder route, and Customers | vpay-api/account-holders-and-customers.md |
The rail callback route (provider_callback.rs) |
vpay-api/provider-callback.md |
Boot (boot.rs) |
vpay-api/boot.md |