Payment Gateways
The payment_gateways module provides a provider-agnostic hub for payment processing. Each payment provider (Stripe, PayU, etc.) is a separate npm package implementing the GatewayAdapter interface — zero provider-specific code lives in core.
Architecture
The system uses a two-layer architecture:
- Core module (
packages/core/src/modules/payment_gateways/) — adapter contract, status machine, webhook routing,GatewayTransactionentity, API endpoints. - Provider packages (
packages/gateway-<provider>/) — each provider implementsGatewayAdapter, registers adapters and webhook handlers, and is developed independently.
Providers plug into the Integration Marketplace for credentials management, health checks, and admin UI.
If a provider needs browser-side payment UI, it registers that UI from widgets/payments/client.tsx. The app bootstrap imports all discovered payment renderer widgets through the generated payments.client.generated.ts entrypoint, and consumer modules resolve them by providerKey + rendererKey.
GatewayAdapter interface
Every provider implements this contract:
import type { GatewayAdapter } from '@saasframe/shared/modules/payment_gateways/types'
const myAdapter: GatewayAdapter = {
providerKey: 'my_provider',
async createSession(input) {
// Create payment intent / checkout session
return { sessionId: '...', status: 'pending', redirectUrl: '...' }
},
async capture(input) {
// Capture an authorized payment
return { status: 'captured', capturedAmount: input.amount ?? 0 }
},
async refund(input) {
// Refund (full or partial)
return { refundId: '...', status: 'refunded', refundedAmount: input.amount ?? 0 }
},
async cancel(input) {
// Cancel / void before capture
return { status: 'cancelled' }
},
async getStatus(input) {
// Query current status from provider
return { status: 'captured', amount: 100, amountReceived: 100, currencyCode: 'USD' }
},
async verifyWebhook(input) {
// Verify signature and parse event
return { eventType: 'payment.captured', eventId: '...', data: {}, idempotencyKey: '...', timestamp: new Date() }
},
mapStatus(providerStatus) {
// Map provider-specific status to unified status
return 'captured'
},
}
Unified payment status
All providers map to a single status enum:
| Status | Meaning |
|---|---|
pending | Session created, awaiting customer action |
authorized | Payment authorized, ready for capture |
captured | Funds captured successfully |
partially_captured | Partial capture completed |
refunded | Full refund issued |
partially_refunded | Partial refund issued |
cancelled | Payment voided before capture |
failed | Payment attempt failed |
expired | Session expired |
Stripe integration from A to Z
The Stripe provider is designed around Payment Intents. Open Saasframe creates the intent through the payment hub, stores it as a GatewayTransaction, and gives your app enough data to either:
- redirect the customer to a hosted checkout page when the provider returns
redirectUrl - render a Stripe Elements card form when the provider returns a
clientSecret
The practical flow is:
- Configure Stripe credentials in External systems -> Integrations -> Stripe.
- Create a payment session with
providerKey: "stripe". - If
redirectUrlis present, send the customer there. - If
clientSecretis present, confirm the payment with Stripe.js. - Refresh status or wait for webhooks to synchronize the transaction.
- Capture, refund, or cancel by transaction ID through the payment hub API.
- Inspect the full lifecycle in External systems -> Payment Transactions.
Provider-owned renderer widgets
Embedded payment UI is owned by the provider package, not by checkout or sales. The contract is:
- gateway descriptor publishes
renderers[]anddefaultRendererKey - session creation may return
clientSession.type = 'embedded'withrendererKey,payload, andsettings - provider package registers the browser widget in
widgets/payments/client.tsx - consumer modules resolve the widget through the shared renderer registry
- redirect-only providers return
clientSession.type = 'redirect'orredirectUrl
Host pages should expose UMES injection spots around the payment-widget host and a behavior spot for validation/submit hooks, so provider-specific or app-specific payment extensions remain inside the widget system.
1. Configure Stripe in admin
Set these credentials in the Stripe integration screen:
Publishable KeySecret KeyWebhook Signing Secret
The current Stripe package exposes these API version slots:
2025-02-24.acaciaas the latest default2024-12-18as a compatibility slot2023-10-16as a deprecated legacy slot
For a real integration, the webhook secret is strongly recommended because Stripe updates payment state asynchronously.
2. Create a payment session
Open Saasframe creates the Stripe Payment Intent through POST /api/payment_gateways/sessions and stores the result as a GatewayTransaction.
const response = await fetch('/api/payment_gateways/sessions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': process.env.SAASFRAME_API_KEY!,
},
body: JSON.stringify({
providerKey: 'stripe',
amount: 49.99,
currencyCode: 'USD',
captureMethod: 'manual',
description: 'Order #100024',
orderId: '7d2d8a9e-7f53-4c1d-bb3c-91fd2fa4a35f',
metadata: {
salesOrderNumber: 'SO-100024',
channel: 'b2c-web',
},
}),
})
if (!response.ok) {
throw new Error(`Create session failed: ${response.status}`)
}
const session = await response.json()
Typical Stripe response:
{
"transactionId": "54cbcc3f-b029-4a84-b145-33122d0eeb16",
"sessionId": "pi_3T9SHbHUCMrz3qXx0JwuemJp",
"providerKey": "stripe",
"clientSecret": "pi_3T9SHbHUCMrz3qXx0JwuemJp_secret_xxx",
"redirectUrl": null,
"providerData": {
"paymentIntentId": "pi_3T9SHbHUCMrz3qXx0JwuemJp",
"publishableKey": "pk_live_xxx"
},
"status": "pending",
"paymentId": "f86c1f5e-ee94-406a-b1a9-dc04c8c3db28"
}
Important fields:
transactionId: Open Saasframe identifier used later for status, capture, refund, cancel, and admin trackingsessionId: Stripe Payment Intent IDclientSecret: used by Stripe.js when your app renders card inputsredirectUrl: if present, send the customer there instead of rendering Stripe ElementsproviderData.publishableKey: safe public key your frontend can use with Stripe.js
3. Make the payment payable
After creating the session, your app must complete one of the following branches.
Branch A: hosted redirect checkout
If the provider returns redirectUrl, send the customer there immediately:
if (session.redirectUrl) {
window.location.assign(session.redirectUrl)
}
Branch B: Stripe Elements / Payment Intents
If Stripe returns a clientSecret, confirm it with Stripe.js:
import { CardElement, Elements, useElements, useStripe } from '@stripe/react-stripe-js'
import { loadStripe } from '@stripe/stripe-js'
const stripePromise = loadStripe(session.providerData.publishableKey)
function CheckoutForm() {
const stripe = useStripe()
const elements = useElements()
async function handleSubmit() {
if (!stripe || !elements) return
const card = elements.getElement(CardElement)
if (!card) return
const result = await stripe.confirmCardPayment(session.clientSecret, {
payment_method: {
card,
},
})
if (result.error) {
throw new Error(result.error.message ?? 'Stripe confirmation failed')
}
console.log('Stripe payment intent status:', result.paymentIntent?.status)
}
return (
<>
<CardElement />
<button onClick={() => void handleSubmit()}>Pay now</button>
</>
)
}
export function StripeCheckout() {
return (
<Elements stripe={stripePromise}>
<CheckoutForm />
</Elements>
)
}
How Stripe intent statuses map into Open Saasframe:
requires_capture->authorizedsucceeded->capturedcanceled->cancelledrequires_payment_method,requires_action,processing->pending
For test payments, Stripe’s standard card works:
4242 4242 4242 4242
Use any future expiry date and any CVC.
4. Refresh and synchronize payment status
Once the payment is confirmed, read status from the payment hub using the transactionId returned earlier:
const statusResponse = await fetch(
`/api/payment_gateways/status?transactionId=${session.transactionId}`,
{
headers: { 'X-Api-Key': process.env.SAASFRAME_API_KEY! },
},
)
const status = await statusResponse.json()
console.log(status.status, status.gatewayStatus)
The status endpoint returns both:
status: the unified Open Saasframe payment statusgatewayStatus: the provider-specific status stored from Stripe
Without webhooks, you can still poll this endpoint, but webhooks are the correct mechanism for keeping authorizations, captures, refunds, failures, and disputes synchronized.
5. Capture, refund, or cancel
If you created the intent with captureMethod: "manual", a successful card confirmation usually moves the transaction to authorized. Your app can then capture the funds:
await fetch('/api/payment_gateways/capture', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': process.env.SAASFRAME_API_KEY!,
},
body: JSON.stringify({
transactionId: session.transactionId,
}),
})
Partial capture:
await fetch('/api/payment_gateways/capture', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': process.env.SAASFRAME_API_KEY!,
},
body: JSON.stringify({
transactionId: session.transactionId,
amount: 20.0,
}),
})
Refund:
await fetch('/api/payment_gateways/refund', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': process.env.SAASFRAME_API_KEY!,
},
body: JSON.stringify({
transactionId: session.transactionId,
amount: 10.0,
reason: 'Customer requested partial refund',
}),
})
Cancel / void before capture:
await fetch('/api/payment_gateways/cancel', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': process.env.SAASFRAME_API_KEY!,
},
body: JSON.stringify({
transactionId: session.transactionId,
reason: 'Customer abandoned checkout',
}),
})
6. Track transactions and logs in admin
The admin UI now includes a payment hub transaction browser:
- External systems -> Payment Transactions
- route:
/backend/payment-gateways
Use it to inspect:
- provider and unified status
- Stripe session and refund IDs
- gateway metadata
- webhook history
- transaction-scoped provider logs
For Stripe integration health, credentials, and version selection, use:
- External systems -> Integrations -> Stripe
Registering adapters
Register adapters and webhook handlers in your provider module's di.ts so they are available at normal app runtime:
import type { AppContainer } from '@saasframe/shared/lib/di/container'
import { registerGatewayAdapter, registerWebhookHandler } from '@saasframe/shared/modules/payment_gateways/types'
import { myAdapter } from './lib/adapter'
import { verifyMyWebhook } from './lib/webhook-handler'
export function register(container: AppContainer) {
registerGatewayAdapter(myAdapter)
registerWebhookHandler('my_provider', verifyMyWebhook, { queue: 'my-provider-webhook' })
}
Versioned adapters are supported — pass { version: '2024-12-18' } to register multiple API versions for the same provider.
Webhook processing
Webhooks flow through an async pipeline:
- Provider sends
POST /api/payment_gateways/webhook/{provider}. - Route handler looks up the registered webhook handler, verifies the signature, and constructs a normalized
WebhookEvent. - Event is enqueued to a dedicated worker queue (e.g.,
stripe-webhook). - Returns
202 Acceptedimmediately. - Worker processes the event: updates
GatewayTransaction, emits domain events, logs viaintegrationLog.
Webhook handlers are idempotent — duplicate events are detected via idempotencyKey.
Stripe webhook setup
Stripe should deliver webhook events into Open Saasframe so the payment hub can update transaction status automatically, append webhook history, and record provider logs. Session creation can work without webhooks, but the integration is incomplete without them.
Production / hosted environments
- Open Stripe Dashboard -> Workbench -> Webhooks.
- Create a webhook endpoint for your Open Saasframe app.
- Set the endpoint URL to:
https://YOUR_APP_URL/api/payment_gateways/webhook/stripe
- Subscribe at minimum to these events:
payment_intent.succeeded
payment_intent.payment_failed
payment_intent.canceled
payment_intent.requires_action
charge.refunded
charge.refund.updated
charge.dispute.created
charge.dispute.closed
- Reveal the endpoint signing secret in Stripe and copy the
whsec_...value into Settings -> Integrations -> Stripe -> Webhook Signing Secret in Open Saasframe.
Local development with ngrok
Stripe cannot deliver webhooks to localhost directly, so expose your local app with a tunnel.
- Start your app locally, for example on
http://localhost:3000. - Run:
ngrok http 3000
- Copy the HTTPS forwarding URL from ngrok.
- In Stripe Dashboard -> Workbench -> Webhooks, create the endpoint:
https://YOUR-NGROK-SUBDOMAIN.ngrok-free.app/api/payment_gateways/webhook/stripe
- Subscribe to the same Stripe events listed above.
- Reveal the endpoint signing secret and paste that
whsec_...value into the Stripe integration credentials form in Open Saasframe.
The webhook route is unauthenticated by design; request authenticity is enforced by Stripe signature verification using the webhook signing secret.
Integration Marketplace
Provider modules declare an IntegrationDefinition in integration.ts with hub: 'payment_gateways' and category: 'payment'. This registers the provider in the marketplace for:
- Credential management (API keys, secrets)
- Enable/disable toggle
- Health check monitoring
- Activity logging
- API version selection
Provider-owned env preconfiguration
Gateway providers can preconfigure themselves from deployment env without adding provider-specific logic to core.
Recommended pattern:
- Read env vars in a provider-local helper such as
lib/preset.ts. - Apply them from the provider module's
setup.ts. - Expose a provider CLI command such as
configure-from-envso operators can rerun the same logic later. - Persist through the normal integration credential/state services.
Stripe supports this today with:
SF_INTEGRATION_STRIPE_PUBLISHABLE_KEY=pk_test_...
SF_INTEGRATION_STRIPE_SECRET_KEY=sk_test_...
SF_INTEGRATION_STRIPE_WEBHOOK_SECRET=whsec_...
SF_INTEGRATION_STRIPE_API_VERSION=2025-02-24.acacia
SF_INTEGRATION_STRIPE_ENABLED=true
SF_INTEGRATION_STRIPE_FORCE_PRECONFIGURE=false
Rerun command:
yarn saasframe gateway_stripe configure-from-env --tenant <tenantId> --org <organizationId>
Credential resolution
Adapters receive credentials resolved by the core service in priority order:
- IntegrationCredentials (marketplace-managed, encrypted)
- SalesPaymentMethod.providerSettings (legacy fallback)
During migration, both stores are updated simultaneously.
Events
The module emits these domain events:
payment_gateways.transaction.created— new payment sessionpayment_gateways.transaction.status_changed— status transitionpayment_gateways.transaction.captured— payment capturedpayment_gateways.transaction.refunded— refund processedpayment_gateways.transaction.cancelled— payment cancelledpayment_gateways.webhook.received— webhook processedpayment_gateways.webhook.failed— webhook verification failed
Mock adapter
A mock gateway is included for development and testing:
import { registerGatewayAdapter } from '@saasframe/shared/modules/payment_gateways/types'
import { mockGatewayAdapter } from './lib/mock-gateway-adapter'
registerGatewayAdapter(mockGatewayAdapter)
The mock adapter simulates the full payment lifecycle in-memory without external API calls.
Publishing gateway providers
Custom payment gateway adapters you build can be published to the Official Modules repository. Gateway providers go through core-team review to ensure security and reliability before being listed on npm — giving your provider visibility and one-command installation for all Open Saasframe users. See Official Modules — Publishing your module for details.