The Stripe adapter lives in @paykernel/core as stripeGateway (BuiltInGatewayName "stripe"). There is no @paykernel/gateway-stripe package. This page documents PayKernel’s mapping. Upstream: Stripe API.
Configuration
import { createPaymentClient, stripeGateway } from "@paykernel/core";
const client = createPaymentClient({
gateways: {
stripe: stripeGateway({
secretKey: process.env.STRIPE_SECRET_KEY!,
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY, // browser only — unused here
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
apiVersion: "2026-02-25.clover", // optional; default is this pin
timeoutMs: 30000,
}),
},
defaultGateway: "stripe",
});Missing webhookSecret throws InvalidRequestError on verify (operator configuration) rather than returning false as if the signature were forged.
publishableKey is for Stripe.js / Elements. It is not used for create/capture/refund/void or webhook verification.
Optional webhookApiVersion rejects snapshot events whose api_version does not match. It is not defaulted from apiVersion — omit it to accept any webhook API version Stripe delivers.
PaymentIntents
import { money } from "@paykernel/core";
const stripe = client.gateway("stripe");
const result = await stripe.createPayment({
amount: money("50", "USD"),
currency: "USD",
callbackUrl: "https://example.com/stripe/return",
description: "Order #1234",
orderId: "order_1234",
stripePaymentMethodId: "pm_card_visa",
stripeCustomerId: "cus_123456789",
capture: true,
idempotencyKey: crypto.randomUUID(),
});Amounts are Money only. The gateway converts to Stripe minor units (zero-decimal, whole-unit specials such as ISK/UGX, three-decimal BHD/JOD/KWD/OMR/TND). Default non-card cap is 8 digits (99_999_999 minor units); per-currency overrides never exceed the 12-digit card max. Stripe remains the source of truth for minimum charge amounts.
When stripePaymentMethodId is provided, the SDK confirms immediately and sends callbackUrl as Stripe return_url when present. Without callbackUrl, it sets automatic_payment_methods.allow_redirects to never.
Metadata: scalar strings/numbers/booleans only; nested objects/arrays rejected. At most 50 keys; key names ≤ 40 chars without square brackets; values ≤ 500 chars.
Checkout Sessions — create is not paid
Capability hostedCheckout. Create returns a Phase 6 outcome union (succeeded with session, or indeterminate). Never fulfill on checkout create. isHostedCheckoutRedirect(result) is true when outcome === "succeeded" and session.url is a non-empty string. Session status stays open; this is not paid settlement.
createCheckoutSession requires a caller idempotencyKey. Payment and subscription modes require cancelUrl. successUrl / cancelUrl must be http(s). Pass either customerId or customerEmail, not both.
import { money } from "@paykernel/core";
const result = await stripe.createCheckoutSession({
mode: "payment",
successUrl: "https://example.com/success",
cancelUrl: "https://example.com/cancel",
metadata: { paymentId: "order_1234" },
idempotencyKey: crypto.randomUUID(),
lineItems: [
{
priceData: {
currency: "USD",
productData: { name: "Premium Plan" },
amount: money("100", "USD"),
},
quantity: 1,
},
],
});
if (result.outcome === "succeeded") {
if (result.session.url) {
redirect(result.session.url);
}
const sessionId = result.session.references.providerObjectId;
} else if (result.outcome === "indeterminate") {
// Timeout / empty / non-JSON 200 after POST. Lookup id is on
// session.references.providerObjectId (caller key, or "unknown").
// Do NOT retry as a fresh session.
}Simple one-item form: amount + currency instead of lineItems (not both). Setup mode requires currency or paymentMethodTypes and does not accept lineItems / amount. Subscription inline priceData must include recurring. Caps: payment mode ≤ 100 line items; subscription ≤ 40 total / ≤ 20 known recurring inline prices. Unsupported Checkout fields are rejected, not ignored.
When Stripe omits url after a successful create, session.url is omitted — do not invent a hosted URL.
getCheckoutSession requires sessionId matching cs_.... HTTP 404 is outcome: "failed" (not a thrown transport error). GET transport failures still throw NetworkError. Expanded PaymentIntent: result.session.references.relatedIds.paymentIntentId. Do not fulfill on GET checkout amount.
Subscription-mode IDs: checkout.session.completed with mode: "subscription" may set webhook gatewayPaymentId to sub_*. capturePayment / refundPayment / voidPayment still require pi_*. Resolve via getCheckoutSession → relatedIds.paymentIntentId or getPayment.
mode: "subscription" does not set capability providerRecurring.
Manual capture, refund, void
const auth = await stripe.createPayment({
amount: money("100", "USD"),
currency: "USD",
callbackUrl: "https://example.com/stripe/return",
stripePaymentMethodId: "pm_card_visa",
capture: false,
idempotencyKey: crypto.randomUUID(),
});
const capture = await stripe.capturePayment({
gatewayPaymentId: auth.gatewayId,
amount: money("100", "USD"),
currency: "USD", // required with amount; must match PI currency
idempotencyKey: crypto.randomUUID(),
});
const refund = await stripe.refundPayment({
gatewayPaymentId: "pi_1234567890", // must be pi_...
amount: money("50", "USD"),
currency: "USD",
reason: "requested_by_customer",
idempotencyKey: crypto.randomUUID(),
});
await client.voidPayment(
{ gatewayPaymentId: "pi_1234567890", idempotencyKey: crypto.randomUUID() },
"stripe",
);Partial capture/refund currency must match the PaymentIntent. The gateway GETs the PI before converting majors. Omit amount for a full capture/refund.
After capture, settled amount is amount_received → latest_charge.amount_captured. Finite settled < authorized → partially_captured. Missing settled fields → processing (fail closed — not full paid).
Stripe-supported refund reasons (duplicate, fraudulent, requested_by_customer) are sent as reason; other strings go to metadata.reason. totalRefunded counts succeeded refunds only. Empty/pending-only lists do not publish totalRefunded: 0.
Idempotency
capturePayment/refundPayment/voidPayment/createCheckoutSessionrequire a calleridempotencyKey. Omitting it throwsInvalidRequestErrorbefore POST. Empty/whitespace-only keys are rejected.createPaymentstill mints an ephemeralcrypto.randomUUID()when omitted, and logs a warning. That key exists only for in-processwithRetry. Crash retries mint a new key and can create a second PaymentIntent.
HTTP 200 empty/non-JSON after a mutating request is NetworkError with afterProviderSubmit: true, mapped to outcome: "indeterminate" + reconciliationRequired: true. Checkout create maps to a checkout-shaped indeterminate result (lookup id on session.references.providerObjectId) — not a payment snapshot. Do not treat as failed / pending / success: true. Do not createPayment / createCheckoutSession again with a new key.
Customers, disputes, payment links
Stripe claims customers and paymentMethods. Create/attach/detach require idempotencyKey. Off-session PaymentIntents need customerId + paymentMethodId (or Stripe aliases). The SDK never accepts raw PAN/CVC (InvalidRequestError). Stripe attachPaymentMethod with a tok_… card token is paymentMethods, not tokenization (Stripe keeps tokenization: false).
listDisputes requires a pi_… or ch_… bound. Empty evidence is rejected. charge.dispute.* dual-writes dispute.opened / updated / closed. Envelope status is the Stripe dispute lifecycle — never generic payment pending. Do not last-write it onto a paid payment.
Payment links are capability paymentLinks — not Checkout Sessions and not PayPal Pay Links. Create/deactivate require idempotencyKey. Do not treat create/deactivate as payment settlement.
Webhooks
Prefer client.handleWebhook("stripe", rawBody, signature). Pass the exact raw body string or Buffer. Parsed JSON objects never verify. parseWebhookEvent alone does not verify authenticity. Thin events: hydrate a snapshot (data.object) first.
const signature = headers.get("stripe-signature") ?? undefined;
const event = await client.handleWebhook("stripe", rawBody, signature);
// verifies only — never fulfill here / never isPaidOutcome(event)Webhook signature uses bidirectional 300s tolerance (Math.abs(now - t) > 300 rejects).
payment_intent.succeeded: amount prefers amount_received → captured. Finite settled < authorized → partially_captured (payment.processing, isPaidOutcome false). Missing settled → processing. Stripe does not decrement amount_received on refund and leaves PI status succeeded — when latest_charge / charges.data[0] shows refunds, domain is refunded / partially_refunded and dual-write is refund.completed (not payment.succeeded). Do not last-write payment_intent.succeeded over charge.refunded.
Checkout payment_status: paid with an unexpanded string payment_intent maps to processing (Stripe leaves payment_status: paid after refunds). Expand payment_intent or fulfill from PI / invoice money events.
Invoice money events (invoice.paid / invoice.payment_succeeded): not always domain paid. void / uncollectible → cancelled / failed. Finite post_payment_credit_notes_amount > 0 → processing. Amount uses amount_paid only. When a PaymentIntent is present: gatewayPaymentId → pi_..., gatewaySubscriptionId → sub_..., gatewayObjectId → in_....
Subscription status (not fulfillment)
Stripe subscription status |
SDK |
|---|---|
active |
processing (not paid) |
trialing / past_due / incomplete / paused / unpaid |
pending |
canceled / incomplete_expired |
cancelled |
| other / unknown | pending |
Never fulfill inventory on subscription domain status alone. Subscription lifecycle and subscription-mode Checkout may set gatewayPaymentId to sub_* and dual-write provider.unmapped.
checkout.session.completed with payment_status: "no_payment_required" and status: "complete": setup/setup_intent → setup_completed; subscription → pending; payment mode ($0 / 100% coupon) → paid; missing mode → pending.
refund.failed maps to domain refund_failed (not payment failed). In-flight refund object statuses → refund_pending. Incomplete charge.refunded snapshots (refunded !== true and missing/zero amount_refunded) → refund_completed (dual-write refund.pending). Webhook amount on charge.refunded is cumulative amount_refunded, not the charge total.
Unhandled event types with foreign statuses (subscription active, etc.) normalize as pending — they do not run through the PaymentIntent fail-closed failed map.