Payment providers
Keep PlatformKit's shared commerce rules while a site supplies its own payment-provider code.
PlatformKit keeps one payment provider active per site. Stripe is the built-in provider and the default for existing sites; a site may instead register its own server-side adapter. The adapter speaks the provider's protocol, while PlatformKit continues to decide what the site sells, record orders and subscriptions, grant access, and make webhook processing idempotent. There is no processor combining, fallback, or automatic migration of customers between providers.
The core includes the existing Stripe adapter and a built-in Hyperswitch adapter. Its browser flow
uses the official Hyperswitch Web SDK's secure card fields: card details go straight from the
customer's browser to Hyperswitch's Vault and never reach PlatformKit. The SDK returns a short-lived
payment-method token, and the checkout page confirms the payment with
/v2/payments/{id}/confirm-intent using that token (payment_token plus a vaulted card_token).
The adapter does not use the SDK's V1 payment-confirm helper and does not accept Stripe.js tokens.
The adapter calls the Router's V2 API: /v2/payments and /v2/refunds for payments and refunds.
Customers, payment-method sessions, and saved methods belong to the Router's payment-method service
group, which the Router serves under one configurable prefix (payment_methods_prefix).
Hyperswitch's Vault SDK guide
creates the session at /v1/payment-method-sessions, and the Web SDK addresses that same path, so the
adapter defaults to v1 (/v1/customers, /v1/payment-method-sessions, and saved methods) and the
Router must be configured to match: ROUTER__MICRO_SERVICES__PAYMENT_METHODS_PREFIX=v1. A Router left
on its stock v2 prefix needs HYPERSWITCH_PAYMENT_METHODS_PREFIX=v2, but the SDK's card fields
then have nothing to call, so that setting is only for non-card use.
With the SDK's card fields, checkout sessions are persistent, for one-time purchases as well as subscriptions. The V2 Router
resolves a payment token from its database, so a volatile (Redis-only) session cannot be confirmed
with confirm-intent. A persistent session needs a running Hyperswitch Card Vault (locker.host),
because the Router vaults the card there. A one-time purchase would therefore leave the vaulted card
on the customer record, so once such a purchase is retrieved as paid the adapter deletes the card
saved during that checkout. It never deletes a card that existed before the payment, subscription
checkouts keep theirs, and a failed deletion is retried on the next retrieval.
A self-hosted Router needs a Web SDK built for it: the SDK's content-security policy only allows
the backend origins it was built with (its build-time backend URL setting), and HYPERSWITCH_CLIENT_URL points at
that build. The hosted SDK cannot call a private Router.
Stripe-backed profiles need Stripe's approval. With the Hyperswitch SDK's card fields the Router
sends the card number to Stripe. Stripe rejects that ("Sending credit card numbers directly to the
Stripe API is generally unsafe") until raw card data APIs are enabled for the account, which only
Stripe support can do (Enabling access to raw card data APIs).
For testing only, support enables it without compliance documents; for live mode Stripe asks for
documentation, and Hyperswitch's PCI compliance page
says to share Hyperswitch's PCI Attestation of Compliance. Hyperswitch's Stripe connector guide
lists this as a prerequisite. PlatformKit has no workaround for this. The Router has no input for a Stripe-issued token
(pm_... or tok_...); a payload that disguises one as another payment method would misrepresent
the payment, so it is not offered. Hyperswitch documents Stripe.js tokens only for Prism, its stateless
connector library
(Connector Token),
which has no routing, vault, retries, or stored state, calls Stripe with a Stripe secret key that
PlatformKit would then hold, and, because its Node package loads a native library through FFI, would
have to run as its standalone gRPC microservice to be called from the Supabase Edge Functions. That
would be a separate adapter, not a change to this one.
A local fake processor for end-to-end tests. The Router's built-in test connectors (fauxpay and
friends) are not served by a V2 build, so packages/kit/build/hyperswitch-fake-processor.mjs is a small
Stripe-shaped test double: run it (node packages/kit/build/hyperswitch-fake-processor.mjs 12111), point
the Router's stripe.base_url at it, and the whole card path runs without Stripe's raw-card approval.
It accepts card numbers, declines 4000000000000002, and supports retrieval and refunds. It proves
the Router, SDK, Card Vault, adapter, retrieval, one-time card cleanup, and refund paths work together; it
says nothing about Stripe, and a result from it must never be reported as a Stripe result. Run against
it in a real browser, a card entered in the SDK fields was vaulted and paid, retrieved as paid through
V2, its saved card deleted, and then refunded; the declined card showed the checkout's error message
and stayed on the checkout page. That run is node packages/kit/build/hyperswitch-browser.integration.mjs <private.json>, where the private file names the running Router, its profile keys, and the SDK URL and
never leaves the machine; it also asserts that no request to the site carried the card number.
Verified in a real browser against a local V2 Router, Card Vault, and self-hosted SDK: the secure
card fields render, the card is vaulted, and the page calls confirm-intent with the token, so the
Router sends the payment to the Stripe connector, which Stripe then refuses until raw card handling is
enabled. A V2 payment on the same Stripe test connector confirmed with a Stripe test token succeeded,
retrieved as paid, and refunded, but that run used a payload the product does not ship, so it only
shows that the Router, retrieval, and refund paths work. A Stripe connector behind Hyperswitch
needs the Stripe account to accept card data from Hyperswitch — Hyperswitch's Stripe connector guide
lists it as a prerequisite, and the account owner asks Stripe to enable it. That is an operator
prerequisite outside PlatformKit, so PlatformKit's browser verification is the fake-processor run
above, and a Stripe-accepted card through the documented SDK path is not claimed. Do not treat unit tests or the card-free Router smoke test
as proof of a successful payment. The configured Stripe connector must use a test key before any
paid test.
Compare the official React demo client,
demo server,
SDK card-confirm helper,
and SDK payment-element renderer.
Also see Hyperswitch's Headless SDK guide,
SDK payment-flow overview,
Token Led Payment guide,
and server-to-server payments guide.
The Token Led guide's payment confirmation is V1; PlatformKit does not use that confirmation path.
The V2 payment_link_enabled flag still returns no hosted URL from the tested Router, and the V2
card_token variant refers to Hyperswitch-vaulted data rather than a Stripe.js token. Server API
and webhook signing keys remain server-side.
PlatformKit manages Hyperswitch subscription changes, recurring periods, cancellation-at-period-end,
and the /billing self-service page (also available at /app/billing), linked from the signed-in
account menu. Immediate upgrades charge
positive prorations through Hyperswitch; a lower-priced immediate change does not issue a prorated
credit. Renewal charges use
the saved payment method and stable period-based idempotency. Prepaid usage top-ups and refunds also
use Hyperswitch. Metered usage is recorded in PlatformKit and charged at period end through the same
Hyperswitch payment path, or in separate threshold charges when the offer opts into incremental
billing. Hyperswitch does not document a native metered-usage event API, so
usage.metered is a PlatformKit capability and is not required in the Hyperswitch profile's
connector capability declaration. The
billing page supports plan changes, cancellation, and resuming a scheduled cancellation, but does
not replace a saved payment method. The Usage section of /billing (/billing#usage) has a Hyperswitch saved-method flow that creates
an authenticated payment-method session on the server and mounts the SDK's payment-method fields.
Saved-method management needs the same Router, Vault, and SDK setup as checkout and has not been
verified end to end.
Unsettled incremental charges appear in the authenticated Usage section of /billing. A saved card
that needs additional verification requires a compatible provider browser SDK; this recovery step
is not verified for Hyperswitch V2. Only a server-verified successful payment clears an outstanding
amount. Stripe requires STRIPE_PUBLISHABLE_KEY only for sites with an offer that opts into
incremental usage; ordinary Stripe checkout does not need it.
The adapter rejects tax and tax-ID collection, adjustable quantities, and shipping collection before
creating a payment. PlatformKit owns the offer catalog and does not create Hyperswitch products or
prices. The local Hyperswitch runner is a V2 API smoke test: it creates a customer, optionally creates a
payment-method session, creates an unpaid checkout intent, and retrieves its status. It does not
submit card data, authorize a payment, or issue a refund. Passing this smoke test does not verify
Stripe card acceptance or browser checkout. The previous API-only runner that posted raw card data
has been removed. A successful Stripe payment, V2 status retrieval, and refund remain
unverified. The V2 health endpoint confirms that the Router is running; it does not confirm that
the Card Vault and its JWE keys are configured. Run the Hyperswitch Card Vault (its /api/v2/vault
routes), set locker.host to it, and use one RSA key pair for the Router's jwekey and the vault's
locker_private_key/tenant public_key, with the vault tenant named public, before testing
payment-method sessions.
As of 2026-10-16, the published SDK's payment-method code is named for V2 but addresses
/v1/payment-method-sessions/..., which a V2 Router serves when its payment-method prefix is v1.
V2 intent creation returns a cs_... client secret. Enabling payment_link_enabled on V2 intent
creation does not return a hosted checkout URL, and V2 create-and-confirm requires payment-method
data. Neither path is a card-free hosted-checkout fallback.
The separate Hyperswitch Vault/Payment Methods SDK flow uses payment-method sessions and payment
tokens; it is not the same integration as Prism's Stripe.js connector-token flow. In the V1 Router
API, payment_token is a Hyperswitch vault reference, not a Stripe pm_... ID, and the Stripe
connector does not implement the hosted PaymentSession operation. Do not substitute a Stripe.js
token or assume a hosted Stripe Checkout fallback for this Router flow.
The repository includes an opt-in Docker Compose override that builds
the router with Hyperswitch v2, OLTP, payout, and tokenization features, runs v2 migrations, and
checks the v2 health endpoint. It uses the Hyperswitch source checkout's Compose dependencies so
the normal unit suite does not need to build or start the service. For the source revision validated
by this stack, use Hyperswitch commit 89cbccdfbb7936bcf0e710f3b4ba42cb4246a3f0.
Start the isolated stack from the PlatformKit repository, pointing at that Hyperswitch checkout:
export HYPERSWITCH_SOURCE_DIR=/path/to/hyperswitch
npm run test:hyperswitch:stackThe router binds to 127.0.0.1:18323 by default. Use
PLATFORMKIT_HYPERSWITCH_TEST_PORT to choose another local port, and
npm run test:hyperswitch:stack:down to stop only this Compose project.
Run the integration check with a private, untracked .env or JSON profile file:
npm run test:hyperswitch:local -- /path/to/private-hyperswitch.envThe runner accepts only a loopback HTTP origin and does not print credentials. To add this live
check to the normal test command explicitly, set
HYPERSWITCH_LOCAL_INTEGRATION_CONFIG=/path/to/private-hyperswitch.env when running npm test.
This verifies the local API and configured test connector only; it does not certify a production
profile, multiple eligible connectors, or routing behavior.
Run the deterministic adapter contract suite from the kit package:
cd packages/kit
node build/payment-provider-adapters.test.mjsFor real Stripe API coverage, pass a private environment file containing a test-mode
STRIPE_SECRET_KEY:
node packages/kit/build/stripe-testmode.integration.mjs /path/to/private-stripe-test.envThe runner refuses keys other than sk_test_ before making a request. It exercises customer and
payment-method setup, catalog synchronization and idempotency, one-time and trial checkout,
promotion-code redemption, subscription plan changes and cancellation, the billing portal, metered
usage, prepaid top-up, and partial plus full refunds. It expires unfinished checkout sessions,
cancels the trial subscription, disables the promotion code and meter, and archives its generated
catalog products. Stripe does not allow archiving a product's current default price, so that
test-only price remains attached to the archived product. Connected-account payout integration is
reported as not verified when the Stripe test account has not enabled Connect; the adapter's
deterministic suite still verifies the payout request mapping.
An incremental usage charge's recovery has its own run against Stripe test mode, with the same private file:
node packages/kit/build/stripe-usage-recovery.integration.mjs /path/to/private-stripe-test.envIt charges a saved card off-session the way the settlement does. A card that needs 3-D Secure comes
back as action required with the client secret the usage page uses, and a retry under the same
idempotency key returns the same PaymentIntent rather than a second charge. The customer's
verification is played on Stripe's test 3-D Secure page in a headless Chromium
(stripe-3ds-complete.mjs; set PLAYWRIGHT_CHROMIUM_EXECUTABLE when Playwright's own build is not
installed), after which retrieval reports the charge paid. A card that saves but declines when
charged is a declined attempt, its key does not charge again, and the retry under a new key with
another card succeeds once. Every charge is refunded and the customer deleted. The verification here
confirms the PaymentIntent server-side; on the usage page Stripe.js does that step with
STRIPE_PUBLISHABLE_KEY.
Hyperswitch payout setup uses a dedicated Hyperswitch customer for each recipient. The recipient
adds a supported payout destination through an authenticated payment-method management session
on the Usage section of /billing; PlatformKit stores only the Hyperswitch customer ID and setup status. For payout
creation, the adapter uses the vaulted payment_token as payout_token with the recipient customer
and payout method type. It does not send raw card or bank details or treat the distinct
payout_method_id external-vault proxy route as implemented. A saved method confirms setup, but
does not prove that a payout connector is active or that a later payout will be accepted. Sites
must configure payout processors and methods in Hyperswitch and declare both customer.create and
payouts.connected-accounts when provider offerings are enabled. After an uncertain request, the
adapter retrieves the same stable payout ID before attempting creation again; nonterminal payout
statuses remain in the ledger for reconciliation and are not recorded as sent. This flow has focused
contract coverage; a live payout has not been verified because the available local profile has no
payout connector. See the
Hyperswitch Payouts API and
saved payout methods guide.
The supported types and adapter contract are in
packages/kit/src/payment-provider.d.ts.
The checks below are made before provider side effects. A provider must implement every method for
each capability it declares; it must not claim a capability and then silently ignore part of an
offer.
Select one provider
Omitting the setting keeps Stripe selected. To use a site-owned provider, select its registered id
in platformkit.config.js and include the module that declares it:
export default {
payments: { provider: 'site-payments' },
build: {
modules: ['./db/modules/payment-provider.mjs'],
},
}The id must belong to an enabled site module. Unknown ids, missing adapter files, malformed capabilities, and missing provider secrets are refused during assembly or deployment.
Register the adapter and its secrets
A site module declares the provider id, the adapter file relative to the site's
db/functions/_shared/ directory, supported capabilities, required server secrets, and the
standard webhook path:
export default {
key: 'site-payments',
what: 'the site payment adapter',
static: false,
git: true,
cloud: true,
owns: { env: ['SITE_PAYMENT_SECRET'] },
paymentProviders: [
{
id: 'site-payments',
adapter: 'payment-providers/provider.ts',
capabilities: ['checkout.one-time', 'checkout.confirm'],
secrets: ['SITE_PAYMENT_SECRET'],
webhookPath: '/functions/v1/payment-provider-webhook',
},
],
}Every name in secrets must also be in the module's owns.env. Keep these values in the site's
ignored local environment file or production deploy environment, never in the module or a
VITE_ variable. Deployment checks require the selected provider's secrets for a site with
something for sale; deployment sends declared module environment values to the function runtime.
The assembled factory receives only the secrets named by that provider registration.
Implement the adapter in the path the module declared. This small mock is for a test fixture only; it deliberately verifies no real webhook and is not a production payment implementation:
export function createPaymentProvider(secrets) {
if (!secrets.SITE_PAYMENT_SECRET) throw new Error('SITE_PAYMENT_SECRET is required')
return {
id: 'site-payments',
capabilities: ['checkout.one-time'],
async createCheckout(input) {
return {
provider: 'site-payments',
checkoutId: input.idempotencyKey,
url: input.successUrl,
}
},
async verifyWebhook() {
return []
},
}
}The factory returns a server-only adapter. verifyWebhook(request) is always required: a real
adapter must authenticate the original request body with its own signature rules, then return
normalized events. The core route at /functions/v1/payment-provider-webhook applies those events
to the shared ledger. The legacy /functions/v1/stripe-webhook route remains available for
existing Stripe configurations. Do not trust a browser redirect as proof of payment.
Adapter operations and capabilities
Methods are optional in the TypeScript contract; a method is required when its corresponding capability is declared. Some capabilities describe terms a checkout must honor, not separate methods.
| Capability | Adapter operation |
|---|---|
checkout.one-time, checkout.subscription |
createCheckout |
checkout.confirm |
retrieveCheckout |
checkout.trial, checkout.tax, checkout.payment-method-selection, checkout.adjustable-quantity, checkout.shipping-address, checkout.shipping-options |
createCheckout, honoring the requested terms |
subscription.change, subscription.proration |
changeSubscription |
customer.create |
createCustomer |
customer.portal |
createPortalSession |
catalog.sync |
syncCatalog |
refunds |
refund |
usage.metered |
reportUsage |
usage.prepaid-top-up |
getDefaultPaymentMethod, createPaymentIntent, and retrievePaymentIntent |
payouts.connected-accounts |
createPayoutAccount, retrievePayoutAccount, and createPayout |
Each operation uses PlatformKit's normalized inputs and results: checkout requests carry offer
keys, quantities, requested tax/shipping terms, and an idempotency key; checkout and subscription
snapshots describe the provider result without making the provider catalog authoritative. The
Hyperswitch webhook adapter maps documented payment, refund, invoice, and payout events; it does
not invent subscription-lifecycle or usage events. Hyperswitch documents invoice_paid as its
subscription-related webhook and does not define a usage event type. Non-terminal payment events
such as processing or authorization do not grant access, and a platform checkout is expired only
when a signed cancellation/expiry event carries PlatformKit's checkout metadata. Unsupported
options are rejected before a request or ledger claim. Hyperswitch itself routes each payment
through the connectors and rules configured in the selected profile; PlatformKit does not select a
connector or hold its credentials.
Hyperswitch configuration
Select Hyperswitch as the site's one payment provider and list the capabilities verified for its
business profile. The built-in adapter implements checkout and PlatformKit-managed subscription
billing, but the Hyperswitch sandbox, multi-connector routing, and production profile have not been
verified. Complete those checks before using it for production sales. Hyperswitch's documented V2
API does not include connected-account payout operations, so the built-in adapter does not expose
payouts.connected-accounts; configuring a payout connector or saved payment method does not make
that capability available.
export default {
payments: {
provider: 'hyperswitch',
profileCapabilities: [
'checkout.one-time',
'checkout.subscription',
'checkout.confirm',
'checkout.trial',
'checkout.payment-method-selection',
'customer.create',
'refunds',
'usage.prepaid-top-up',
],
},
}The selected profile must be configured in Hyperswitch with eligible payment and payout connectors,
payment methods, and routing rules. profileCapabilities is the site's assertion about
provider-backed behavior and must be a subset of the capabilities implemented by the built-in
adapter. Unsupported capabilities are rejected before their operations run. PlatformKit-managed
subscription scheduling, usage accounting, and the billing page do not need to be asserted as
native Hyperswitch features. Hyperswitch does not provide an aggregate capability query for a
profile. PlatformKit keeps its catalog and offer files authoritative and does not create
Hyperswitch products or prices.
Set HYPERSWITCH_BASE_URL, HYPERSWITCH_API_KEY, HYPERSWITCH_PROFILE_ID,
HYPERSWITCH_PUBLISHABLE_KEY, and HYPERSWITCH_WEBHOOK_SECRET in the server environment. The API
key and webhook signing key never enter browser assets. The publishable key, payment client secret,
payment-method session authorization, and session ID are returned only for the checkout tab.
HYPERSWITCH_CLIENT_URL is optional; the default is Hyperswitch's hosted V2 SDK base. A custom URL
must end in /v2 (or be a loopback URL for local development), and the browser loads
HyperLoader.js from it directly. The webhook secret is the profile's
payment_response_hash_key. Do not set Stripe credentials for a Hyperswitch site. The adapter
authenticates the raw webhook body before normalizing events. Checkout is a focused, branded page
with an order summary and provider-controlled secure card fields; invalid or expired links provide
a clear return to the store.
The official Web SDK loader still contains V1 payment-method-session paths, and the Token-Led guide
documents V1 payment confirmation. PlatformKit uses the SDK only for provider-controlled card
entry and V2 session confirmation; it then sends the returned Hyperswitch session token to V2
confirm-intent with a scoped publishable key and client secret. This keeps card data out of
PlatformKit and avoids the V1 payment-confirm helper. The V2 session-token flow is wired but has
not yet passed a real browser payment and issuer-authentication test. A successful paid-status
retrieval and refund are also unverified. Do not treat unit tests, the SDK asset check, or the
card-free Router smoke test as proof of a successful payment. The configured Stripe connector must
be verified as test mode before any paid test. See the
Token-Led Payment guide,
V2 payment-method-session confirmation,
V2 payment confirmation, and the
official Web SDK session helper.
Payment controls in admin
The admin Payments page shows the selected provider, the Hyperswitch capabilities declared for the
site, and a validated Control Center link when payments.controlCenterUrl is configured. It also
links to orders and, for Stripe, the Stripe Dashboard. It is an operations overview, not a place to
edit provider credentials or profile capabilities: keep API and webhook keys in the server
environment, change profileCapabilities in the site configuration, and manage connectors and
routing in Hyperswitch. The page shows the recipient's payout setup state; it cannot inspect or
configure the Hyperswitch payout connector itself.
Keep the offer files authoritative
The existing Stripe path remains content/store/products/*.md → catalog.json →
push-catalog. Stripe is still the compatibility default, and the site files remain the source
of truth for prices, product identity, and access rules. A custom provider that needs mirrored
products or prices can declare catalog.sync and implement syncCatalog; then the same build
step calls the selected adapter. A provider that does not need catalog mirroring leaves that
capability out, and the catalog-push step is skipped.
Payment references and webhook event ids are stored with provider identity, so two providers returning the same external id cannot share a customer, purchase, subscription, or event marker. Existing Stripe references keep their Stripe identity and their established checkout behavior.
The site-owned mock used to check module registration and assembly is in
packages/kit/build/fixtures/site-payment-provider.
See the store,
memberships and pricing,
usage billing,
edge functions,
configuration keys, and
environment variables for the behavior at each
payment surface.