@vaam-apps/vpay-sdk 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +732 -0
  3. package/dist/auth.d.ts +180 -0
  4. package/dist/auth.js +297 -0
  5. package/dist/client.d.ts +75 -0
  6. package/dist/client.js +79 -0
  7. package/dist/errors.d.ts +91 -0
  8. package/dist/errors.js +112 -0
  9. package/dist/form.d.ts +47 -0
  10. package/dist/form.js +127 -0
  11. package/dist/http.d.ts +33 -0
  12. package/dist/http.js +115 -0
  13. package/dist/index.d.ts +15 -0
  14. package/dist/index.js +14 -0
  15. package/dist/resources/account-holders.d.ts +36 -0
  16. package/dist/resources/account-holders.js +46 -0
  17. package/dist/resources/balance.d.ts +8 -0
  18. package/dist/resources/balance.js +10 -0
  19. package/dist/resources/checkout-sessions.d.ts +55 -0
  20. package/dist/resources/checkout-sessions.js +127 -0
  21. package/dist/resources/customers.d.ts +97 -0
  22. package/dist/resources/customers.js +157 -0
  23. package/dist/resources/events.d.ts +8 -0
  24. package/dist/resources/events.js +10 -0
  25. package/dist/resources/invoices.d.ts +193 -0
  26. package/dist/resources/invoices.js +275 -0
  27. package/dist/resources/payment-intents.d.ts +12 -0
  28. package/dist/resources/payment-intents.js +44 -0
  29. package/dist/resources/refunds.d.ts +92 -0
  30. package/dist/resources/refunds.js +106 -0
  31. package/dist/stripe-auth.d.ts +177 -0
  32. package/dist/stripe-auth.js +192 -0
  33. package/dist/types.d.ts +1268 -0
  34. package/dist/types.js +48 -0
  35. package/dist/validate.d.ts +16 -0
  36. package/dist/validate.js +20 -0
  37. package/dist/version.d.ts +12 -0
  38. package/dist/version.js +12 -0
  39. package/dist/webhooks.d.ts +22 -0
  40. package/dist/webhooks.js +94 -0
  41. package/package.json +63 -0
  42. package/scripts/mint-assertion.mjs +73 -0
package/README.md ADDED
@@ -0,0 +1,732 @@
1
+ # @vaam-apps/vpay-sdk
2
+
3
+ The Node.js merchant SDK for vpay's `/v1` API. Implements the wire contract in
4
+ [`docs/flows/merchant-auth.md`](../../docs/flows/merchant-auth.md) exactly —
5
+ `private_key_jwt` client assertions, `client_credentials` token exchange and
6
+ caching, the form-encoded resource calls, and outbound-webhook verification.
7
+
8
+ **Not yet on the registry.** `npm view @vaam-apps/vpay-sdk` answered `E404` on
9
+ 2026-09-05 and no workflow in this repository publishes anything. What changed
10
+ on that date is only that the manifest stopped saying `"private": true`, so a
11
+ release can happen without editing it; the release workflow that would do the
12
+ publishing does not exist yet. See "Status" below for what the server it talks
13
+ to actually serves.
14
+
15
+ ## Install
16
+
17
+ Inside this workspace:
18
+
19
+ ```bash
20
+ pnpm --filter @vaam-apps/vpay-sdk build
21
+ ```
22
+
23
+ From a registry, once a release workflow exists and has run:
24
+
25
+ ```bash
26
+ pnpm add @vaam-apps/vpay-sdk # not yet published — see above
27
+ ```
28
+
29
+ Requires Node.js `>=22.11.0`.
30
+
31
+ ## The handshake, in prose
32
+
33
+ `/v1` never accepts an API key ([ADR-0010](../../docs/adr/0010-merchant-auth-private-key-jwt.md)).
34
+ Every merchant is a statically registered OAuth2 client authenticating with
35
+ `client_credentials` (RFC 6749 §4.4) via a signed `private_key_jwt` assertion
36
+ (RFC 7523). Concretely:
37
+
38
+ 1. **Mint an assertion.** The SDK signs a short-lived RS256 JWT with your
39
+ private key: `iss`/`sub` are your `client_id`, `aud` is the OP's own token
40
+ endpoint (see `assertionAudience`), `jti` is a fresh UUIDv4, and `exp` is
41
+ `now + assertionLifetimeSeconds` (default 60s, capped at 300s — the OP
42
+ refuses anything further out).
43
+ 2. **Exchange it for an access token.** The SDK `POST`s the assertion to the
44
+ token endpoint alongside `grant_type=client_credentials` and
45
+ `audience=vpay:v1`. No `client_secret` is ever sent — there is nothing to
46
+ send; vpay stores only your **public** key.
47
+ 3. **Call `/v1` with the token.** Every resource call carries
48
+ `Authorization: Bearer <access_token>`. The SDK caches the token until
49
+ shortly before it expires and reuses it across calls; concurrent calls
50
+ share one in-flight token request.
51
+ 4. **Re-authenticate on expiry or a 401.** There is no refresh token by
52
+ design. On a `401` from a resource route, the SDK discards the cached
53
+ token, repeats steps 1–2 once, and retries the original request exactly
54
+ once. A second `401` is returned to you as a `VpayApiError`.
55
+
56
+ ## Usage
57
+
58
+ ```ts
59
+ import { readFileSync } from "node:fs";
60
+ import { VpayClient } from "@vaam-apps/vpay-sdk";
61
+
62
+ const vpay = new VpayClient({
63
+ baseUrl: "https://api.vpay.example",
64
+ clientId: "merchant_a",
65
+ privateKey: readFileSync("./merchant_a.key.pem", "utf8"),
66
+ });
67
+
68
+ const intent = await vpay.paymentIntents.create(
69
+ {
70
+ amount: 5000, // 5,000 FCFA — XAF is zero-decimal, see docs/flows/money.md
71
+ currency: "xaf",
72
+ payment_method_types: ["mtn_momo"],
73
+ metadata: { order_id: "1234" },
74
+ },
75
+ { idempotencyKey: "order_1234_attempt_1" },
76
+ );
77
+
78
+ console.log(intent.id, intent.status); // pi_..., "requires_payment_method"
79
+
80
+ const confirmed = await vpay.paymentIntents.confirm(intent.id, {
81
+ payment_method_data: {
82
+ type: "mtn_momo",
83
+ mtn_momo: { msisdn: "237670000000" },
84
+ },
85
+ });
86
+
87
+ // `processing` means NOT YET. Wait for a payment_intent.succeeded webhook,
88
+ // or poll retrieve(). There is no `failed` status — see payment-lifecycle.md.
89
+ console.log(confirmed.status);
90
+
91
+ // Orange Money is a redirect rail:
92
+ const redirectConfirm = await vpay.paymentIntents.confirm(intent.id, {
93
+ payment_method_data: { type: "orange_money" },
94
+ return_url: "https://shop.example/return",
95
+ });
96
+ if (redirectConfirm.next_action?.type === "redirect_to_url") {
97
+ // redirect the payer's browser to redirectConfirm.next_action.redirect_to_url.url
98
+ }
99
+
100
+ await vpay.paymentIntents.retrieve(intent.id);
101
+ await vpay.paymentIntents.cancel(intent.id);
102
+ await vpay.paymentIntents.list({ limit: 10 });
103
+
104
+ await vpay.refunds.create({
105
+ payment_intent: intent.id,
106
+ reason: "requested_by_customer",
107
+ });
108
+ await vpay.events.list({ type: "payment_intent.succeeded", limit: 20 });
109
+ await vpay.balance.retrieve();
110
+
111
+ // Checkout Sessions (Step 9). A session references an intent you already
112
+ // created; it never creates one, and it carries where the payer goes next.
113
+ const hosted = await vpay.checkout.sessions.create({
114
+ payment_intent: intent.id,
115
+ ui_mode: "hosted",
116
+ // vpay substitutes {CHECKOUT_SESSION_ID} when it forwards the payer, and
117
+ // appends nothing else. Omit it and the return has no correlation.
118
+ success_url: "https://shop.example/ok?sid={CHECKOUT_SESSION_ID}",
119
+ cancel_url: "https://shop.example/cancel",
120
+ });
121
+ // Send the payer to hosted.url — the session secret is in its fragment.
122
+
123
+ const embedded = await vpay.checkout.sessions.create({
124
+ payment_intent: intent.id,
125
+ ui_mode: "embedded",
126
+ return_url: "https://shop.example/order/42",
127
+ });
128
+ // Hand embedded.client_secret to @vaam-apps/vpay-stripe-js's initEmbeddedCheckout.
129
+
130
+ await vpay.checkout.sessions.retrieve(hosted.id);
131
+ await vpay.checkout.sessions.list({ payment_intent: intent.id, limit: 10 });
132
+ await vpay.checkout.sessions.expire(hosted.id); // open -> expired
133
+ ```
134
+
135
+ `create()` and `retrieve()` responses carry a `client_secret` (`intent.client_secret`,
136
+ typed `string | undefined`) — the payer credential `/v1/browser` accepts for a
137
+ browser-side confirm. Hand it straight to `@vaam-apps/vpay-stripe-js` on the page you
138
+ render for the payer; never log it or send it anywhere but the browser. It is
139
+ the one field on `PaymentIntent` that is genuinely absent — not `null` — from
140
+ every other response shape: a `list()` item and `event.data.object` never
141
+ carry it, so a merchant's own listing view or a stored/forwarded webhook body
142
+ never receives a live payer credential for intents it did not just create.
143
+
144
+ A Checkout Session's own `client_secret` (`cs_…_secret_…`) is a **different**
145
+ credential from the intent's: it authorises reading that session, never
146
+ confirming the intent. Like the intent's, it is present on `create()` and
147
+ `retrieve()` only and absent from every `list()` item. Unlike the intent's, it
148
+ does not print: every `CheckoutSession` this SDK returns redacts it — and the
149
+ fragment of a hosted session's `url`, which holds the same value — from
150
+ `util.inspect`, so `console.log(session)` cannot leak it. `JSON.stringify` is
151
+ left faithful, because an embedded integration has to serialise the secret to
152
+ get it to the browser at all.
153
+
154
+ ## Configuration
155
+
156
+ | Option | Default | Notes |
157
+ | -------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
158
+ | `baseUrl` | — | Required, e.g. `https://api.vpay.example`. |
159
+ | `clientId` | — | Required. Your registered OAuth2 `client_id`. |
160
+ | `privateKey` | — | Required. PEM text or a `crypto.KeyObject`. Never logged, never serialized. |
161
+ | `kid` | — | Required only if you registered more than one JWK. |
162
+ | `issuer` | `${baseUrl}/v1/oauth` | This default is what the server does: `vpay_api::op::issuer_for` builds the issuer as `{public_base_url}/v1/oauth` from the deployment YAML, and `vpay_api::router` mounts the OP there. Override only for a deployment behind a path prefix. |
163
+ | `tokenEndpoint` | `${issuer}/token` | The URL this package POSTs the token request to. A **reachability** setting: it must resolve from wherever your server runs. It is also the default `assertionAudience`, but the two are separate settings — see the row below. |
164
+ | `assertionAudience` | `tokenEndpoint` | The client assertion's `aud` claim: the OP's own token endpoint (or issuer) **as vpay is configured publicly** — `{deployment.public_base_url}/v1/oauth/token`. Set it when your server reaches vpay by a different URL than payers do. See "Reaching vpay by an internal URL" below. |
165
+ | `audience` | `vpay:v1` | The OAuth2 `audience` request parameter. Load-bearing: without it the OP mints a token whose `aud` is the `client_id`, which the resource server rejects. Server-side the same string is `vpay_config::MERCHANT_AUDIENCE`; this package keeps its own copy, so the two must change together. |
166
+ | `scope` | — | Omitted from the token request unless set. |
167
+ | `assertionLifetimeSeconds` | `60` | Must be an integer in `1..=300`; anything else throws `VpayConfigError` at construction, not at request time. Keep the default — see the note below the table. |
168
+ | `timeoutMs` | `30000` | Applies to both the token exchange and every resource call. |
169
+ | `fetch` | global `fetch` | Injection point for tests or an outbound proxy. |
170
+
171
+ **Do not raise `assertionLifetimeSeconds` to 300.** 300 is not a comfortable
172
+ maximum, it is the OP's exact refusal boundary
173
+ (`MAX_CLIENT_ASSERTION_LIFETIME_SECS`), and the OP compares it against _its_
174
+ clock. A merchant clock running even one second fast mints an `exp` the OP
175
+ reads as 301 seconds out, and every assertion is refused — the failure looks
176
+ like `invalid_client`, arrives all at once, and clears up on its own when the
177
+ clocks drift back. The default of 60 leaves 240 seconds of headroom for a
178
+ value that only has to outlive one HTTP round trip.
179
+
180
+ ### Reaching vpay by an internal URL
181
+
182
+ Two things look like one and are not:
183
+
184
+ - **`tokenEndpoint`** — where this process sends the HTTP request. It has to
185
+ be reachable from _your_ server: a compose service name, a private DNS
186
+ name, a service-mesh address.
187
+ - **`assertionAudience`** — what the OP calls _itself_. The OP's
188
+ `authenticate_client` compares the assertion's `aud` claim against its own
189
+ two names, `{deployment.public_base_url}/v1/oauth/token` and
190
+ `{deployment.public_base_url}/v1/oauth`, and against nothing else. The URL
191
+ you happened to POST to is never consulted.
192
+
193
+ They coincide for a merchant on the public internet, which is why
194
+ `assertionAudience` defaults to `tokenEndpoint`. They do not coincide the
195
+ moment your server reaches vpay by an internal name, and then every token
196
+ request answers `invalid_client` (`InvalidAudience`) with nothing in the
197
+ response pointing at the cause — the signature, the `client_id`, the `kid`
198
+ and the lifetime are all correct.
199
+
200
+ ```ts
201
+ // The demo stack: the shop's container reaches vpay at the compose service
202
+ // name, but vpay's `deployment.public_base_url` is the published host port.
203
+ const vpay = new VpayClient({
204
+ baseUrl: "http://vpay-server:8080",
205
+ assertionAudience: "http://localhost:8080/v1/oauth/token",
206
+ clientId: "shop-merchant",
207
+ privateKey,
208
+ });
209
+ ```
210
+
211
+ The issuer works too — `http://localhost:8080/v1/oauth` — because the OP
212
+ accepts either.
213
+
214
+ ## Error handling
215
+
216
+ Every error extends `VpayError`, so `catch (err) { if (err instanceof VpayError) ... }`
217
+ works as a catch-all; narrow further with `instanceof` on a specific subclass:
218
+
219
+ | Class | When | Fields |
220
+ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
221
+ | `VpayApiError` | A non-2xx response from a `/v1` resource route. | `status`, `type`, `code`, `message`, `param` |
222
+ | `VpayAuthError` | A `400`/`401` from the token endpoint (`invalid_client`, etc). Never retried. | `error`, `errorDescription` |
223
+ | `VpayUnexpectedResponseError` | A response that isn't the shape this SDK expects — a proxy's HTML 502, an empty body, or a token-endpoint 200 that is not `{ access_token, token_type: "Bearer", expires_in }` (a non-`Bearer` `token_type` lands here, not in `VpayAuthError`: nothing failed to authenticate, the SDK simply cannot present that token). | `status`, `bodyPrefix` (first 500 bytes) |
224
+ | `VpayTransportError` | DNS, TLS, connection refused, timeout. | `cause` (the underlying error) |
225
+ | `VpayConfigError` | The SDK was misconfigured — caught at construction where possible. | — |
226
+ | `WebhookSignatureError` | `verifyWebhook` rejected a signature. | — |
227
+
228
+ ```ts
229
+ import { VpayApiError, VpayAuthError, VpayError } from "@vaam-apps/vpay-sdk";
230
+
231
+ try {
232
+ await vpay.paymentIntents.create({
233
+ amount: 5000,
234
+ currency: "xaf",
235
+ payment_method_types: ["mtn_momo"],
236
+ });
237
+ } catch (err) {
238
+ if (err instanceof VpayApiError) {
239
+ console.error(err.status, err.type, err.code, err.message, err.param);
240
+ } else if (err instanceof VpayAuthError) {
241
+ console.error("authentication failed:", err.error, err.errorDescription);
242
+ } else if (err instanceof VpayError) {
243
+ console.error(err.name, err.message);
244
+ }
245
+ }
246
+ ```
247
+
248
+ `VpayApiError` is one class for every error envelope: the envelope's `type`
249
+ is a _field_, not a subclass. Branch on `code`, which is what the server
250
+ treats as the machine-readable answer — two idempotency errors share a status
251
+ (`400`) and a `type` (`idempotency_error`) and mean opposite things:
252
+
253
+ ```ts
254
+ if (err instanceof VpayApiError) {
255
+ if (err.code === "idempotency_key_in_flight") {
256
+ // The first request under this key has not finished. Wait, then send
257
+ // the same call again — do not mint a new key.
258
+ } else if (err.code === "idempotency_key_in_use") {
259
+ // The key was already used with a different body. Retrying cannot help.
260
+ }
261
+ }
262
+ ```
263
+
264
+ ## Webhook verification
265
+
266
+ ```ts
267
+ import { createServer } from "node:http";
268
+ import { verifyWebhook, WebhookSignatureError } from "@vaam-apps/vpay-sdk";
269
+
270
+ // Read the secret once, at startup, so a misconfigured deployment fails
271
+ // loudly instead of rejecting every delivery as a bad signature. Under
272
+ // `noUncheckedIndexedAccess` this is `string | undefined` until you narrow it.
273
+ const webhookSecret = process.env["VPAY_WEBHOOK_SECRET"];
274
+ if (webhookSecret === undefined) {
275
+ throw new Error("VPAY_WEBHOOK_SECRET is not set");
276
+ }
277
+
278
+ createServer((req, res) => {
279
+ let raw = "";
280
+ req.on("data", (chunk: Buffer) => (raw += chunk.toString("utf8")));
281
+ req.on("end", () => {
282
+ // `IncomingHttpHeaders` types every header as `string | string[]`, because
283
+ // a header may legitimately be repeated. A repeated `Vpay-Signature` is
284
+ // not something vpay sends, and joining or picking one arbitrarily would
285
+ // verify against bytes nobody promised — so reject it.
286
+ const header = req.headers["vpay-signature"];
287
+ if (typeof header !== "string") {
288
+ res.writeHead(400).end("missing or repeated Vpay-Signature");
289
+ return;
290
+ }
291
+
292
+ let event;
293
+ try {
294
+ // The RAW request body must be used — parsing and re-stringifying it
295
+ // breaks the HMAC. Do not run a body-parsing middleware before this.
296
+ event = verifyWebhook({
297
+ rawBody: raw,
298
+ signatureHeader: header,
299
+ secret: webhookSecret,
300
+ });
301
+ } catch (err) {
302
+ if (err instanceof WebhookSignatureError) {
303
+ res.writeHead(400).end("bad signature");
304
+ return;
305
+ }
306
+ throw err;
307
+ }
308
+
309
+ // Delivery is at-least-once. verifyWebhook does not dedupe by event.id —
310
+ // that is your job. Keep a set (or a unique DB constraint) of processed
311
+ // event.id values and skip anything you've already handled.
312
+ console.log("event", event.id, event.type);
313
+ res.writeHead(200).end("ok");
314
+ });
315
+ }).listen(4242);
316
+ ```
317
+
318
+ ## Using the official Stripe SDK
319
+
320
+ vpay's `/v1` surface is Stripe-shaped, and the official
321
+ [`stripe`](https://www.npmjs.com/package/stripe) Node SDK can talk to it —
322
+ with an **empty API key** and a `config.authenticator` that performs the
323
+ `private_key_jwt` handshake above. `@vaam-apps/vpay-sdk/stripe` is that authenticator:
324
+
325
+ ```js
326
+ import { readFileSync } from "node:fs";
327
+ import Stripe from "stripe";
328
+ import { createStripeAuthenticator } from "@vaam-apps/vpay-sdk/stripe";
329
+
330
+ const authenticator = createStripeAuthenticator({
331
+ baseUrl: "http://localhost:8080",
332
+ clientId: "acme-cameroon",
333
+ privateKey: readFileSync("./merchant-key.pem", "utf8"),
334
+ kid: "acme-cameroon-2026-08",
335
+ });
336
+
337
+ const stripe = new Stripe("", {
338
+ authenticator,
339
+ host: "localhost",
340
+ port: "8080",
341
+ protocol: "http",
342
+ maxNetworkRetries: 2,
343
+ timeout: 30_000,
344
+ telemetry: false,
345
+ });
346
+
347
+ const intent = await stripe.paymentIntents.create({
348
+ amount: 5000,
349
+ currency: "xaf",
350
+ payment_method_types: ["mtn_momo"],
351
+ });
352
+ ```
353
+
354
+ `host`, `port` and `protocol` are not optional. The authenticator is bound to
355
+ `baseUrl`'s origin: a request addressed anywhere else throws `VpayConfigError`
356
+ before a token is minted. Omit them and stripe-node addresses
357
+ `api.stripe.com:443`, which is exactly the case this refusal exists to stop.
358
+ Behind a reverse proxy, or on split-horizon DNS, set `baseUrl` to the origin
359
+ this client actually connects to rather than the public one — both the token
360
+ exchange and the binding follow `baseUrl`, so a mismatch either mints the
361
+ token against an unreachable endpoint or refuses every request as addressed
362
+ elsewhere.
363
+
364
+ `stripe` is an **optional peer dependency** of this package: install it
365
+ yourself if you want this entry point, and `@vaam-apps/vpay-sdk`'s core entry never
366
+ imports it. This package itself still has **zero runtime dependencies**.
367
+
368
+ The empty first argument is not a trick — stripe-node's `_setAuthenticator`
369
+ refuses only _both_ an API key and an authenticator, or _neither_. The token
370
+ cache, its single-flight refresh and its `expires_in` safety margin are the
371
+ same ones `VpayClient` uses, not a second implementation.
372
+
373
+ `authenticator.invalidate()` discards the cached token. You need it because an
374
+ authenticator runs _before_ a request and its promise settles before any
375
+ status code exists — it cannot see a `401` and re-auth the way `VpayClient`
376
+ does:
377
+
378
+ ```js
379
+ try {
380
+ await stripe.paymentIntents.retrieve(id);
381
+ } catch (err) {
382
+ if (err instanceof Stripe.errors.StripeAuthenticationError) {
383
+ authenticator.invalidate();
384
+ }
385
+ throw err;
386
+ }
387
+ ```
388
+
389
+ ### Where vpay diverges from Stripe
390
+
391
+ - **No API keys, ever.** `sk_live_…` means nothing here; authentication is the
392
+ OAuth2 `client_credentials` + `private_key_jwt` handshake above
393
+ ([ADR-0010](../../docs/adr/0010-merchant-auth-private-key-jwt.md)). There is
394
+ no publishable key and no Connect equivalent.
395
+ - **`Idempotency-Key` is mandatory on every `/v1` POST**, not optional as at
396
+ Stripe. This costs you nothing: stripe-node puts a
397
+ `stripe-node-retry-<uuid>` key on every v1 POST unconditionally — _including_
398
+ when `maxNetworkRetries` is `0`. Pass your own with
399
+ `{ idempotencyKey: "order_1234_attempt_1" }` when you want the retry window
400
+ to span more than one process.
401
+ - **XAF and EUR only.** XAF is zero-decimal: `amount: 5000` is 5,000 FCFA, not
402
+ 50.00 (see [docs/flows/money.md](../../docs/flows/money.md)).
403
+ - **`payment_method_types` is required and non-empty**, and must name an
404
+ enabled vpay rail. A copy-pasted `automatic_payment_methods: { enabled: true }`
405
+ gets a `400` naming `payment_method_types`.
406
+ - **No `confirm: true` on create, no `search`, no Connect.** Confirm with a
407
+ second call to `stripe.paymentIntents.confirm(id, …)`: `confirm: true` on
408
+ create is **refused** with `param: "confirm"` and a message naming the
409
+ confirm route. `stripe.paymentIntents.search(…)` and every `stripeAccount` /
410
+ Connect parameter have no vpay counterpart.
411
+ - **The fields that decide where or when money moves are refused, not
412
+ ignored.** `capture_method` with any value other than `automatic`,
413
+ `application_fee_amount`, `transfer_data` and `on_behalf_of` come back as a
414
+ `400` naming the field in `error.param`. vpay has no authorise-now /
415
+ capture-later split — confirming _is_ the charge — and no Connect, so
416
+ ignoring any of them would move a merchant's money somewhere, or at a time,
417
+ they did not ask for and could not see in the response.
418
+
419
+ Everything else Stripe sends and vpay does not implement is **accepted and
420
+ ignored**, because none of it changes that: `setup_future_usage`,
421
+ `confirmation_method`, `receipt_email`, `statement_descriptor`, `customer`,
422
+ `expand` and `metadata` (`metadata` is actually stored) all leave the
423
+ payment exactly as requested. Nothing is expandable, so an `expand` request
424
+ simply produces no expanded field — visible in the response itself.
425
+
426
+ - **Rail codes are vpay's, not Stripe's.** `payment_method_types` entries and
427
+ `payment_method_data.type` are `mtn_momo` / `orange_money`; stripe-node's
428
+ TypeScript types enumerate Stripe's own rails and reject them, so a cast is
429
+ needed at those call sites — the values are right, the _type_ is Stripe's:
430
+
431
+ ```ts
432
+ await stripe.paymentIntents.confirm(id, {
433
+ payment_method_data: {
434
+ type: "mtn_momo",
435
+ mtn_momo: { msisdn: "237670000000" },
436
+ },
437
+ } as unknown as Stripe.PaymentIntentConfirmParams);
438
+ ```
439
+
440
+ - **`429` is never emitted.** Nothing in vpay constructs a rate-limit
441
+ category, so `Stripe.errors.StripeRateLimitError` cannot occur. Do not build
442
+ a backoff path around it.
443
+ - **`client_secret` is present, but only on `create`/`retrieve`.** vpay's
444
+ `create()` and `retrieve()` responses carry it — see "Usage" above — for
445
+ `@vaam-apps/vpay-stripe-js`'s browser-side confirm flow. `amount_received`,
446
+ `capture_method` and `confirmation_method` stay genuinely absent, though:
447
+ stripe-node's TypeScript types declare all three as present on every
448
+ response shape — it casts responses, it never validates them — so these
449
+ read as `undefined` at runtime while the type says otherwise, on every
450
+ route including `create`/`retrieve`.
451
+ - **`Stripe-Version`, `Stripe-Account`, `Stripe-Context` and the
452
+ `X-Stripe-Client-*` headers are accepted and ignored.** vpay advertises no
453
+ dated API version, so `obj.lastResponse.apiVersion` is `undefined` and
454
+ pinning `apiVersion` has no effect.
455
+
456
+ ### Webhooks
457
+
458
+ vpay signs outbound deliveries with the same construction Stripe uses —
459
+ `t=<unix>,v1=<lowercase hex HMAC-SHA256 of "<t>.<raw body>">` — so
460
+ `stripe.webhooks.constructEvent(rawBody, header, secret)` verifies a vpay
461
+ delivery byte for byte. Read the header value, not the request object, and
462
+ give it the **raw** body:
463
+
464
+ ```js
465
+ const event = stripe.webhooks.constructEvent(
466
+ rawBody,
467
+ req.headers["stripe-signature"],
468
+ process.env.VPAY_WEBHOOK_SECRET,
469
+ );
470
+ ```
471
+
472
+ **Either header works, and that snippet is observed rather than argued.**
473
+ `vpay_worker`'s deliverer sends `Vpay-Signature` and `Stripe-Signature`
474
+ carrying the same string, byte for byte, and
475
+ `sdks/stripe-compat`'s `webhooks.compat.test.ts` takes a delivery out of a
476
+ WireMock receiver's request journal and puts it through
477
+ `stripe.webhooks.constructEvent` exactly as written above — then requires the
478
+ same call to throw `StripeSignatureVerificationError` for a body with one byte
479
+ flipped and for the right body with the wrong secret.
480
+
481
+ `Stripe-Signature` exists only so that a copy-pasted Stripe recipe needs no
482
+ edit at all. Code written from scratch should read the authoritative name
483
+ instead — the same call, one word changed, verifying the same bytes:
484
+
485
+ ```js
486
+ const event = stripe.webhooks.constructEvent(
487
+ rawBody,
488
+ req.headers["vpay-signature"],
489
+ process.env.VPAY_WEBHOOK_SECRET,
490
+ );
491
+ ```
492
+
493
+ **`Vpay-Signature` is the authoritative name** whatever else is added beside
494
+ it — prefer it in code you write from scratch, and use this package's own
495
+ `verifyWebhook` if you are not otherwise using stripe-node.
496
+
497
+ ### A stripe-node defect you will hit if your key is wrong
498
+
499
+ **Measured against `stripe@22.6.1`, not inferred.** When the vpay handshake
500
+ fails — a bad key, an unregistered `client_id`, vpay unreachable — the
501
+ authenticator rejects, as it must. stripe-node builds the right error from it
502
+ (`StripeError: Unable to authenticate the request`, with the underlying
503
+ `VpayAuthError` at `err.raw.exception`) but **throws it inside a detached
504
+ promise chain that never calls its own callback**. The result: the error
505
+ arrives as a process-level `unhandledRejection`, and the promise you awaited
506
+ **never settles at all** — not even after `timeout`, because no HTTP request
507
+ was ever started for a timeout to fire against.
508
+
509
+ Two of the ways a key can be wrong no longer reach that path at all. A
510
+ `privateKey` that `node:crypto` cannot read, and a `baseUrl` that is not an
511
+ absolute URL, are both `VpayConfigError` thrown by `createStripeAuthenticator`
512
+ **at construction** — before a `Stripe` instance exists, on the line the
513
+ merchant wrote. What is left for the warning below is the set of failures only
514
+ the OP can report: an unregistered `client_id`, a key whose public half vpay
515
+ does not hold, a clock too far out, vpay unreachable.
516
+
517
+ Until stripe-node fixes this, do not rely on `try`/`catch` around a call to
518
+ surface a handshake failure. Two things that do work:
519
+
520
+ - **Verify the handshake at startup**, where a rejection is yours to catch.
521
+ The authenticator is a plain function, so call it once against a throwaway
522
+ request object and let it throw before you serve traffic — a `VpayAuthError`
523
+ there names the OAuth2 reason (`invalid_client`, …) directly:
524
+
525
+ ```js
526
+ await authenticator({ headers: {} });
527
+ ```
528
+
529
+ - **Install a process-level guard** so a mid-flight handshake failure is at
530
+ least logged and alerted on rather than silently hanging a request:
531
+ `process.on("unhandledRejection", …)`.
532
+
533
+ `src/stripe-auth.test.ts` pins this behaviour, so if a future `stripe` release
534
+ routes the failure to the caller the test fails and this warning gets deleted.
535
+
536
+ ### Status
537
+
538
+ `createStripeAuthenticator` is **built and tested**. Its tests run against a
539
+ real `node:http` server and, for the end-to-end case, drive the **real
540
+ `stripe` package**: `new Stripe("", { authenticator, host, port, protocol })`
541
+ creating a PaymentIntent, with the stub asserting the `Authorization` header
542
+ the authenticator minted, the form-encoded body, and stripe-node's
543
+ auto-generated `Idempotency-Key`. The type-level assertion that the returned
544
+ function is assignable to `Stripe.StripeConfig["authenticator"]` is checked by
545
+ `pnpm --filter @vaam-apps/vpay-sdk typecheck`.
546
+
547
+ What is **not** proven here:
548
+
549
+ - **Nothing in this package has run against a real `vpay-server`.** The
550
+ end-to-end test's server is a stub in this repository. What _has_ run
551
+ against a real one is
552
+ [`sdks/stripe-compat`](../stripe-compat/) — the official `stripe` package
553
+ driven through this authenticator against a live compose stack, 25 cases,
554
+ run by CI's `e2e (compose)` job. It is a separate package on purpose: it
555
+ needs a stack, and a suite that needs a stack must not be able to report
556
+ green in a job that has none.
557
+ - **Every webhook verified so far was delivered to a WireMock receiver on a
558
+ compose network.** The _signature_ is now proven three ways — against this
559
+ package's own `verifyWebhook`, against `vpay_sdk::webhooks::verify_at`, and
560
+ against the official `stripe` package's `constructEvent` — but **no merchant
561
+ endpoint has ever been POSTed to**, and there is no SSRF protection on the
562
+ destination ([`docs/flows/webhooks.md`](../../docs/flows/webhooks.md)).
563
+ - **The `stripe` peer range (`^22.6.1`) is asserted, not derived.** It is the
564
+ current major at the time of writing, tested against exactly `22.6.1`.
565
+ Nothing pins vpay against a future `stripe` release; a dependency bump is
566
+ what will surface a break.
567
+ - **The server-side compatibility work is not in this package**, though it
568
+ has now landed beside it: the `request-id` response header stripe-node
569
+ reads, `stripe-should-retry` derived from `Classify::retry`, and refusing
570
+ `confirm: true` on create rather than ignoring it are all in `vpay-api`,
571
+ with their own unit and integration tests, and are exercised end to end by
572
+ `sdks/stripe-compat`. See
573
+ [`docs/flows/stripe-sdk-compat.md`](../../docs/flows/stripe-sdk-compat.md).
574
+
575
+ ## `scripts/mint-assertion.mjs`
576
+
577
+ Mints one assertion and prints it, alongside the public JWK derived from the
578
+ same private key, as JSON on stdout — used to feed a real OP verifier (e.g. a
579
+ Rust example driving `authkestra_op::client_assertion::verify_client_assertion`
580
+ directly) without standing up a server:
581
+
582
+ ```bash
583
+ pnpm --filter @vaam-apps/vpay-sdk build
584
+ VPAY_CLIENT_ID=merchant_a \
585
+ VPAY_PRIVATE_KEY_FILE=./merchant_a.key.pem \
586
+ VPAY_AUDIENCE=https://api.vpay.example/v1/oauth/token \
587
+ node sdks/nodejs/scripts/mint-assertion.mjs
588
+ ```
589
+
590
+ ## Status
591
+
592
+ **Corrected 2026-09-03.** This section used to say "what it does **not**
593
+ have is a single `/v1` resource route" — true when it was written on
594
+ 2026-09-02, and false since Step 2 landed the next day.
595
+
596
+ `vpay-server` mounts the merchant OP — `POST /v1/oauth/token`,
597
+ `GET /v1/oauth/jwks.json`,
598
+ `GET /v1/oauth/.well-known/openid-configuration` — puts a bearer-token
599
+ boundary in front of every other `/v1` path, and **serves four
600
+ payment-intent routes past it**: create, retrieve and list on
601
+ `/v1/payment_intents`, plus `/{id}`, `/{id}/confirm` and `/{id}/cancel`. A
602
+ confirm reaches a real rail adapter over HTTP and moves the intent to
603
+ `processing`, and `vpay-worker` then polls the rail and settles it, so an
604
+ intent does reach **`succeeded`** — `sdks/stripe-compat` watches one do so
605
+ through `paymentIntents.retrieve` and nothing else. `/v1/events`,
606
+ `/v1/events/{id}` (Step 5) and `GET /v1/refunds/{id}` (issue #45,
607
+ 2026-09-05) are served, and so are `/v1/customers` (S4a) and — since
608
+ 2026-09-07 — `/v1/invoices` and `/v1/invoice_items` (S4b), which this
609
+ package has wrapped since 2026-09-08. `POST /v1/refunds` and `/v1/balance`
610
+ are still the honest `404 unknown_route`. ~~**This package has no client method for any of
611
+ that beyond the payment-intent routes it already wraps.**~~ **— corrected
612
+ 2026-09-05: it has `refunds.retrieve(id)`,** added in the same change that
613
+ served the route, so this package can now make the refund read a
614
+ reconciliation job needs when a `charge.refund.updated` was missed or
615
+ arrived out of order. It still has no `events.retrieve` — a dated gap in
616
+ [`../../docs/sdks/parity.md`](../../docs/sdks/parity.md) — and
617
+ `refunds.create` and `balance.retrieve` still reach the 404.
618
+
619
+ **`checkout.sessions` is newer than this paragraph and less proven than the
620
+ rest.** The four methods are implemented and tested against this package's
621
+ `node:http` stub, in the shape of Step 9's wire contract. The routes they
622
+ call — `/v1/checkout/sessions` and its three siblings — are built by lane 1
623
+ of the same step; until that lands and
624
+ `backends/tests/integration/tests/checkout_sessions.rs` is green, "the stub
625
+ answers the way this SDK expects" is the whole of the evidence, and a
626
+ mismatch would show up as a `VpayApiError` rather than as anything this
627
+ package could catch earlier.
628
+
629
+ **No test in _this package_ has ever talked to a vpay** — every server in
630
+ these tests is a `node:http` stub started by the test. What has talked to a
631
+ real one is `sdks/stripe-compat`, which drives the official `stripe` package
632
+ through this package's own `createStripeAuthenticator` against a live
633
+ compose stack (25 cases, CI's `e2e (compose)` job), and the Rust SDK's
634
+ `backends/tests/integration/tests/merchant_token_flow.rs`. See
635
+ [`docs/status.md`](../../docs/status.md),
636
+ [`docs/flows/merchant-auth.md`](../../docs/flows/merchant-auth.md) and
637
+ [`docs/flows/stripe-sdk-compat.md`](../../docs/flows/stripe-sdk-compat.md).
638
+
639
+ What the tests in this package **do** prove. Everything that touches HTTP
640
+ runs against a real `node:http` server started by the test (never a mocked
641
+ `fetch`) and asserts the actual bytes sent on the wire; the form encoder,
642
+ the webhook verifier and the error-body bounding are pure unit tests, since
643
+ none of them touches the network:
644
+
645
+ - The client assertion this SDK mints has every claim and header field the
646
+ wire contract specifies, is a fresh `jti` per mint, and its RS256 signature
647
+ verifies with `node:crypto` against the matching public key — and fails
648
+ against a different one.
649
+ - The full `client_credentials` + `private_key_jwt` token exchange: exact
650
+ form fields, content type, and the resulting `Authorization: Bearer`
651
+ header on the following resource call.
652
+ - Token caching (`expires_in` minus the safety margin), single-flight
653
+ refresh under concurrent callers, and the single 401-triggered re-auth
654
+ retry (including that a second consecutive 401 surfaces as `VpayApiError`
655
+ without a third token request).
656
+ - Every resource method's exact HTTP method, path, headers, and
657
+ bracket-encoded body — including that a caller-supplied `Idempotency-Key`
658
+ is honoured and one is generated when omitted.
659
+ - That `create()` and `retrieve()` surface `client_secret` typed (`string`)
660
+ when the server sends it, and that a `list()` item without one reads back
661
+ `undefined` rather than a cast or a runtime crash.
662
+ - All four `checkout.sessions` operations: the exact path, method,
663
+ `Idempotency-Key` and body of `create` (including that an unset `ui_mode`
664
+ or URL is omitted rather than sent empty), the `GET` paths of `retrieve`
665
+ and `list` with its `payment_intent` filter, `expire`'s empty-bodied
666
+ `POST`, percent-encoding of a hostile id on both, and the `404`/`409`
667
+ envelopes. The body strings are the ones `sdks/rust/tests/resources.rs`
668
+ pins byte for byte, so the two SDKs cannot drift.
669
+ - That a `CheckoutSession`'s `client_secret` — and the fragment of a hosted
670
+ session's `url`, which carries the same value — are redacted from
671
+ `util.inspect` while `JSON.stringify` stays faithful.
672
+ - The form encoder's nested-object/array/boolean/percent-encoding rules, and
673
+ that a non-integer `amount` throws `TypeError` before any request is sent.
674
+ - Error mapping for a Stripe-shaped `400`, an unexpected `502` HTML body, a
675
+ connection failure, and a token-endpoint `401` (which is never retried).
676
+ - Webhook verification: valid/invalid signatures, timestamp tolerance,
677
+ multi-signature secret rotation, malformed headers, a one-byte body change,
678
+ and that a `Buffer` body and its string form verify identically.
679
+ - That `util.inspect(client)` and `JSON.stringify(client)` never contain the
680
+ private key, and that neither the client nor any error this SDK throws
681
+ carries an access token into `util.inspect` output.
682
+ - All thirteen invoice operations (S4b, added 2026-09-08): the exact body of
683
+ `invoices.create` and of a create that supplies nothing but the two required
684
+ fields (`customer` and `currency` — the second was optional for one day, and
685
+ a running vpay answers `400` without it); the `GET`
686
+ whose response carries the lines; the update's three states (`due_date=` is
687
+ _clear it_, an unmentioned field is absent, and a patch that mentions
688
+ nothing sends nothing); the list's two cursors and two filters; the
689
+ `DELETE` with its key and no body; that `finalize`, `void` and
690
+ `markUncollectible` each `POST` an **empty** body to its own path while
691
+ still sending a content type (the third method is camelCase and its route,
692
+ `/v1/invoices/{id}/mark_uncollectible`, is not — the case asserts the path
693
+ by name); that a refused transition arrives as a
694
+ `VpayApiError` carrying the server's `409` whole; that `invoices.pay` sends
695
+ both URLs and answers an invoice still `"open"` with a `payment_intent` and
696
+ a `hosted_invoice_url`; the four `invoiceItems` methods, including that
697
+ neither `currency` nor `amount` is ever in the body; and that a
698
+ `unit_amount` outside the safe-integer range throws `TypeError` before any
699
+ request. Every body string is the one `sdks/rust/tests/resources.rs` pins,
700
+ asserted as a literal on both sides.
701
+ - **Against a real `vpay-server`, not a stub** (added 2026-09-08):
702
+ `src/invoices.live.test.ts` creates a customer, drafts an invoice, bills two
703
+ lines, finalizes it, reads it back, checks the stored lines add up to the
704
+ stored total, lists it, voids it and finds it keeps its number; then
705
+ finalizes a second and has `pay` mint a payment intent and a hosted URL
706
+ without charging anybody; and confirms `currency` is required. It is a
707
+ **separate vitest project** (`vitest.live.config.ts`, `pnpm test:live`) so
708
+ `pnpm test` stays runnable with no stack, and it **fails rather than
709
+ skipping** when there is none — `just sdk-live` is what brings one up.
710
+ - That the four `invoice.*` types are in `KnownEventType` and narrow through
711
+ `isInvoiceEvent`, that `isPaymentIntentEvent` and `isCheckoutSessionEvent`
712
+ do **not** claim them, and that an event body's empty `lines` decodes
713
+ through the same `Invoice` type a `/v1` response's populated `lines` does.
714
+
715
+ **Separately — and this is _not_ one of the tests above — the assertion has
716
+ been checked against the real OP verifier by hand.** An assertion minted by
717
+ this SDK was fed directly to
718
+ `authkestra_op::client_assertion::verify_client_assertion` at the pinned
719
+ `authkestra-op = "=0.7.1"`, against a `ClientRegistration` holding the
720
+ corresponding public JWK, and was accepted. `just sdk-conformance-node`
721
+ reproduces it: the recipe mints an assertion via `scripts/mint-assertion.mjs`
722
+ and pipes it into `cargo run -p vpay-sdk --example verify_assertion`. It is a
723
+ manual check, run on demand — it needs a Rust toolchain, it is not part of
724
+ `pnpm test`, and no CI job gates on it. Nothing in the list above depends on
725
+ it having been run.
726
+
727
+ What this does **not** prove: that any of this works against a real vpay
728
+ deployment. Nothing in this package has been run against `vpay-server`; the
729
+ `issuer`/`tokenEndpoint` defaults agree with `vpay_api::op::issuer_for` by
730
+ inspection, not by a test in this repository that exercises them together,
731
+ and the resource routes every method here calls do not exist server-side at
732
+ all — see [`docs/status.md`](../../docs/status.md).