From 1ffce87da382a584159b6027aac2c3d84bde98dd Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Sat, 11 Apr 2026 08:31:06 -0700 Subject: [PATCH 1/2] fix: surface Stripe error details in PaymentIntent failures The catch blocks in createWithClient and createWithSecretKey were swallowing the actual Stripe API error, making it impossible to debug HTML test failures. Now the error detail (SDK message or API response body) is included in the VerificationFailedError reason. --- src/stripe/server/Charge.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts index 89bfce5d..ab0ca9e9 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -207,8 +207,11 @@ async function createWithClient(parameters: { // https://docs.stripe.com/error-low-level#idempotency const replayed = result.lastResponse?.headers?.['idempotent-replayed'] === 'true' return { id: result.id, status: result.status, replayed } - } catch { - throw new VerificationFailedError({ reason: 'Stripe PaymentIntent failed' }) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new VerificationFailedError({ + reason: `Stripe PaymentIntent failed: ${detail}`, + }) } } @@ -244,7 +247,20 @@ async function createWithSecretKey(parameters: { body, }) - if (!response.ok) throw new VerificationFailedError({ reason: 'Stripe PaymentIntent failed' }) + if (!response.ok) { + const body = await response.text().catch(() => '') + const detail = (() => { + try { + const parsed = JSON.parse(body) as { error?: { message?: string } } + return parsed.error?.message ?? body + } catch { + return body + } + })() + throw new VerificationFailedError({ + reason: `Stripe PaymentIntent failed: ${detail}`, + }) + } // https://docs.stripe.com/error-low-level#idempotency const replayed = response.headers.get('idempotent-replayed') === 'true' const result = (await response.json()) as { id: string; status: string } From 5f5fd41306988777f96adcf58165e2cb1b0e7e5c Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Sat, 11 Apr 2026 08:37:45 -0700 Subject: [PATCH 2/2] fix: add Stripe preview version header for SPT support Stripe now requires a '.preview' Stripe-Version header to use shared_payment_granted_token (SPTs). Add the header to both createWithClient (SDK) and createWithSecretKey (raw fetch) paths, and to the test server's SPT creation endpoint. --- src/stripe/internal/constants.ts | 7 +++++++ src/stripe/server/Charge.ts | 4 +++- test/html/server.ts | 3 +++ 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 src/stripe/internal/constants.ts diff --git a/src/stripe/internal/constants.ts b/src/stripe/internal/constants.ts new file mode 100644 index 00000000..af293921 --- /dev/null +++ b/src/stripe/internal/constants.ts @@ -0,0 +1,7 @@ +/** + * Stripe API version with `.preview` suffix. + * + * Required for `shared_payment_granted_token` (SPTs are in private preview). + * Bump this when upgrading to a newer Stripe API version. + */ +export const stripePreviewVersion = '2026-02-25.preview' diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts index ab0ca9e9..cef42abe 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -5,6 +5,7 @@ import type { LooseOmit, OneOf } from '../../internal/types.js' import * as Method from '../../Method.js' import type * as Html from '../../server/internal/html/config.ts' import type * as z from '../../zod.js' +import { stripePreviewVersion } from '../internal/constants.js' import type { StripeClient, CreatePaymentMethodFromElements, @@ -202,7 +203,7 @@ async function createWithClient(parameters: { // `shared_payment_granted_token` is not yet in the Stripe SDK types (SPTs are in private preview). shared_payment_granted_token: spt, } as any, - { idempotencyKey: `mppx_${challenge.id}_${spt}` }, + { idempotencyKey: `mppx_${challenge.id}_${spt}`, apiVersion: stripePreviewVersion }, ) // https://docs.stripe.com/error-low-level#idempotency const replayed = result.lastResponse?.headers?.['idempotent-replayed'] === 'true' @@ -243,6 +244,7 @@ async function createWithSecretKey(parameters: { Authorization: `Basic ${btoa(`${secretKey}:`)}`, 'Content-Type': 'application/x-www-form-urlencoded', 'Idempotency-Key': `mppx_${challenge.id}_${spt}`, + 'Stripe-Version': stripePreviewVersion, }, body, }) diff --git a/test/html/server.ts b/test/html/server.ts index 13d52c7e..2483ef5b 100644 --- a/test/html/server.ts +++ b/test/html/server.ts @@ -6,6 +6,8 @@ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import { tempoModerato } from 'viem/chains' import { Actions } from 'viem/tempo' +import { stripePreviewVersion } from '../../src/stripe/internal/constants.js' + export async function startServer(port: number): Promise { const stripePublishableKey = process.env.VITE_STRIPE_PUBLIC_KEY const stripeSecretKey = process.env.VITE_STRIPE_SECRET_KEY @@ -190,6 +192,7 @@ async function createSharedPaymentToken(request: Request, secretKey: string): Pr headers: { Authorization: `Basic ${btoa(`${secretKey}:`)}`, 'Content-Type': 'application/x-www-form-urlencoded', + 'Stripe-Version': stripePreviewVersion, }, body: bodyParams, })