Skip to content
Built and testedas of vpay v0.4.1

Stripe SDK compatibility ​

vpay's /v1 object model, form encoding, error envelope and idempotency are Stripe's; its authentication is not. The official stripe package (stripe-node) cannot mint a private_key_jwt assertion by itself — but it accepts an arbitrary async config.authenticator, invoked once per request attempt, and @vaam-apps/vpay-sdk/stripe supplies one. With it, an existing stripe-node integration reaches vpay with an empty API key.

This is proven, not argued: sdks/stripe-compat drives the real stripe package against a live compose stack in CI's e2e (compose) job. What it proves, and where the two APIs differ, is below. Agents working on the SDKs should load vpay-sdks.

The client ​

js
import { readFileSync } from "node:fs";
import Stripe from "stripe";
import { createStripeAuthenticator } from "@vaam-apps/vpay-sdk/stripe";

const authenticator = createStripeAuthenticator({
  baseUrl: "https://api.vpay.example",
  clientId: "acme-cameroon",
  privateKey: readFileSync("./merchant-key.pem", "utf8"),
  kid: "acme-cameroon-2026-08", // only if you registered more than one JWK
});

const stripe = new Stripe("", {
  authenticator,
  host: "api.vpay.example",
  port: "443",
  protocol: "https",
  maxNetworkRetries: 2,
  timeout: 30_000,
  telemetry: false,
});

stripe-node refuses only when both a key and an authenticator are given, or neither, so new Stripe("", { authenticator }) is legal. host, port and protocol move every request off api.stripe.com; the generated resources use absolute paths (/v1/payment_intents, /v1/payment_intents/{id}/confirm) that are exactly vpay's. The authenticator writes only headers.Authorization — it must not touch the body, whose length is computed before it runs.

examples/merchant-stripe-node is a runnable version: create, confirm on the MTN push rail, and poll paymentIntents.retrieve until succeeded — against a WireMock rail.

sequenceDiagram
  autonumber
  participant App as Merchant code
  participant S as stripe-node
  participant A as vpay authenticator
  participant OP as vpay OAuth provider
  participant V as vpay /v1
  App->>S: stripe.paymentIntents.create(params)
  S->>A: authenticate this outbound request
  A->>OP: POST /v1/oauth/token (private_key_jwt), when no cached token
  OP-->>A: access_token
  A-->>S: sets headers.Authorization only
  S->>V: POST /v1/payment_intents (form body, Idempotency-Key)
  V-->>S: payment_intent, with request-id and x-request-id
  S-->>App: typed object

What carries over unchanged ​

BehaviourWhy it works
Resource pathsstripe-node hardcodes the same /v1/payment_intents paths vpay serves
Form encodingstripe-node's indexed arrays (payment_method_types[0]=…) and %2B for + are what vpay's decoder takes
Idempotency-Keystripe-node sends one on every v1 POST, which vpay requires
Lists and auto-paginationautoPagingToArray needs only data[].id and has_more
Error envelope{ error: { type, code, message, param? } } from the same closed vocabulary
webhooks.constructEventvpay's signature construction is byte-identical; see Webhooks
err.requestIdvpay sends request-id (the only name stripe-node reads) as well as x-request-id, one value

Which error class you catch ​

stripe-node picks the class from the status first, and vpay derives status, type and code from one classification — so this mapping is a property of the two designs meeting.

vpay answerWhat you catch
404 resource_missingStripeInvalidRequestError, err.code === "resource_missing"
400 invalid_requestStripeInvalidRequestError, err.param naming the field
400 idempotency_errorStripeIdempotencyError
401 authentication_errorStripeAuthenticationError
403 (missing scope)StripePermissionError, because stripe-node branches on the status
409 (lifecycle conflict)StripeAPIError
502 (rail transport)StripeAPIError — and stripe-node retries it
429never emitted

vpay sets stripe-should-retry on every error it renders: false on a 409 (which stripe-node would otherwise retry unconditionally) and true on an in-flight idempotency key (which it would otherwise never retry). A replayed response carries the advisory the original did.

Every divergence ​

Areavpay's behaviour
API keys, stripeAccount, Connectnone exist; Stripe-Account, Stripe-Version, Stripe-Context and X-Stripe-Client-* are accepted and ignored
API versionnone advertised or echoed; lastResponse.apiVersion is undefined and pinning apiVersion does nothing
payment_method_typesrequired, non-empty, each a rail this deployment enabled; automatic_payment_methods is dropped and the create is refused for the missing field
confirm: true on createrefused with param: "confirm"; confirm=false is accepted
payment_method_data.typea rail code such as mtn_momo, with the instrument under the same key (payment_method_data[mtn_momo][msisdn]); TypeScript needs a cast
capture_method (not automatic), application_fee_amount, transfer_data, on_behalf_ofrefused with a 400 naming the field, on create and confirm — ignoring them would move money at a time or to an account nobody asked for
setup_future_usage, confirmation_method, receipt_email, statement_descriptor, expandaccepted and ignored; nothing is expandable
metadata, customerstored and rendered on every read; a cus_… that is not yours is a 400 naming customer
client_secreton create and retrieve only; absent from confirm, cancel, list and every webhook body
amount_received, capture_method, confirmation_method on the objectabsent, though stripe-node's types declare them
next_actiononly ever redirect_to_url, only on a redirect rail; null on a push rail
CurrenciesXAF and EUR in integer minor units; a confirm whose currency is not the rail's settlement currency is a 400
Status vocabularyno failed; a refused charge returns the intent to requires_payment_method with last_payment_error
In-flight idempotency key400, where Stripe answers 409
search, /v1/balancenot routed; 404 unknown_route
refunds.createneeds destination[<rail>][msisdn], which Stripe has no parameter for; a 201 is a pending refund no rail has ever settled
refunds.updatemetadata only, as Stripe — but an unaccepted parameter is a 400 naming it, not parameter_unknown
405 and 413produced below the error renderer, with no JSON body — stripe-node throws "Invalid JSON received from the Stripe API" with statusCode undefined; only err.requestId survives
Checkoutvpay's checkout.session is its own (no line_items, mode or amount_total); the compat suite makes no claim about it
Rustno async-stripe twin; Rust merchants use vpay-sdk

A 502 from the rail is re-POSTed

stripe-node retries a 502 unconditionally, under the same Idempotency-Key. The retry meets vpay's one-charge-per-intent rule and comes back as a 409. That is the predicted consequence of the headers — no test observes it, because the compose stack's WireMock rails cannot be steered into a transport failure from outside.

What the suite proves ​

sdks/stripe-compat runs the real [email protected] package, out of process over TCP, against a real vpay-server, Postgres, worker, WireMock rails and a WireMock webhook receiver. Its setup fails the run if no stack answers or the handshake does not complete, so it cannot skip. It covers create, retrieve, cursor paging including autoPagingToArray, cancel, confirm to processing and a poll to succeeded; a delivered webhook verified with stripe.webhooks.constructEvent and refused for a tampered body and a wrong secret; the request-id mirror; ignored Stripe-Version/Stripe-Account; expand through stripe-node's own encoding; the error classes above with err.requestId; each refused money-moving field naming itself; the 409's stripe-should-retry: false; idempotent replay and a changed body under the same key; and the 405/413 collapse.

Not proven:

  • the stripe-should-retry: true direction (provoking an in-flight key deterministically would need a test double in a shipping process);
  • the 502 re-POST above;
  • stripe.refunds.* and stripe.events.list() through stripe-node — the routes exist, but no case drives them;
  • anything about a real rail: the succeeded the suite polls to is a WireMock mapping answering SUCCESSFUL, driven through the real worker and settlement;
  • delivery to a real endpoint: the receiver is WireMock too;
  • any stripe release other than 22.6.1.

Status in v0.4.1 ​

PartStatusEvidence
stripe-node through createStripeAuthenticatorBuilt and testedsdks/stripe-compat: 25 cases, 0 skipped when measured on 2026-09-03; runs in CI's e2e (compose) job
Webhook verification with constructEventBuilt and testedwebhooks.compat.test.ts, over bytes read from the receiver's journal
Refunds and events through stripe-node's resourcesWritten, never run against the real railthe routes are served; no compat case calls them
A 405/413 stripe-node can readNot builtno envelope renderer for either
Rust (async-stripe) glueNot builtscoped as a follow-up
Against a real railNot builtevery rail in the suite is WireMock

The full record is docs/flows/stripe-sdk-compat.md § Status.

Go deeper ​

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