#Payment Providers
A PaymentProvider is the bridge between Alepha's lifecycle and a real payment service provider. The abstract class defines eleven required methods covering the intent lifecycle and saved cards, plus two a real provider should implement:
1abstract class PaymentProvider { 2 createSession(intent, { returnUrl, authorize?, saveCard?, label?, customerEmail?, idempotencyKey?, stripeAccount?, applicationFeeAmount? }): Promise<{ url, providerRef }>; 3 capturePayment(providerRef, amount, options?): Promise<void>; 4 voidPayment(providerRef, options?): Promise<void>; 5 refundPayment(providerRef, amount, options?): Promise<{ providerRef }>; 6 parseWebhook(request): Promise<WebhookEvent>; // { providerRef, providerRefAlt?, status, account?, raw } 7 createPaymentMethod(userId, token, options?): Promise<CreatePaymentMethodResult>; 8 deletePaymentMethod(providerRef, options?): Promise<void>; 9 expireSession(providerRef, options?): Promise<void>;10 11 // saved cards: a setup session saves a card without a payment, and the12 // saved card can later be charged with the payer absent13 createSetupSession(userId, { returnUrl, metadata?, stripeAccount?, idempotencyKey? }): Promise<{ url, providerRef }>;14 retrieveSavedPaymentMethod(userId, providerRef, options?): Promise<CreatePaymentMethodResult | null>;15 chargeOffSession(userId, paymentMethodRef, amount, options): Promise<{ status, providerRef?, code? }>;16 17 // optional: embedded card fields (Stripe Payment Element and friends);18 // PaymentService.supportsEmbeddedPayment() dispatches on its presence19 createElementSession?(intent, options?): Promise<{ clientSecret, publishableKey, provider, providerRef }>;20 21 // non-abstract, returns null by default - but providers SHOULD override it:22 // it is the reconciliation path when a webhook goes missing. The shipped23 // provider does.24 retrieveSessionStatus(providerRef, options?): Promise<"authorized" | "captured" | "failed" | null>;25}
Every options? above is ProviderAccountOptions, { stripeAccount?, idempotencyKey? }, except createElementSession's, which is { stripeAccount? }; chargeOffSession takes { currency?, applicationFeeAmount?, metadata?, idempotencyKey?, stripeAccount? }. CreatePaymentMethodResult is { providerRef, type, brand?, last4?, expMonth?, expYear? }.
createSession options carry Connect-style fields (stripeAccount, applicationFeeAmount). Every later call about that session takes the same { stripeAccount }: a Stripe direct charge lives on the connected account only, and the platform account cannot see it. PaymentService records the account on the intent (providerAccount) when it creates the session and passes it on every poll, expiry, capture, void and refund, so a provider only has to honour the option.
createElementSession returns a providerRef too: the PSP's own id for the payment the browser confirms (a Stripe PaymentIntent). PaymentService stores it, with the account, before handing the client secret back, so the webhook that settles the payment matches the intent the same way a redirect session's does, and the stale-intent sweep can poll it. expireSession receives that ref for an abandoned embedded payment and must leave it unpayable: the Stripe provider cancels the PaymentIntent (cancellation_reason: "abandoned"), since a PaymentIntent, unlike a Checkout session, never expires on its own.
PaymentService and PaymentMethodService call these methods; you never call them directly. The one exception is chargeOffSession, which no framework service wraps: inject PaymentProvider to charge a saved card off-session. It answers succeeded, failed, or requires_action when the bank wants the payer back (3-D Secure).
One implementation ships with the framework, @alepha/payments-stripe. It composes with AlephaApiPayments like this:
1import { AlephaPaymentsStripe } from "@alepha/payments-stripe";2import { Alepha } from "alepha";3import { AlephaApiPayments } from "alepha/api/payments";4 5const alepha = Alepha.create()6 .with(AlephaApiPayments)7 .with(AlephaPaymentsStripe);
AlephaPaymentsStripe imports AlephaApiPayments itself, and its register calls alepha.with({ provide: PaymentProvider, use: StripePaymentProvider }), so the MemoryPaymentProvider default is replaced automatically.
#Stripe
npm install @alepha/payments-stripe
#Environment
| Variable | Description |
|---|---|
STRIPE_SECRET_KEY |
API key (sk_test_... / sk_live_...). |
STRIPE_WEBHOOK_SECRET |
Signing secret returned by webhookEndpoints.create. |
STRIPE_PUBLISHABLE_KEY |
Optional. Required for the embedded Payment Element - createElementSession throws without it. |
STRIPE_CONNECT_WEBHOOK_SECRET |
Optional. Signing secret for Connect webhooks; gates parseConnectWebhook. |
#Webhook security
Stripe signs webhook payloads with HMAC-SHA256. StripePaymentProvider.parseWebhook calls stripe.webhooks.constructEventAsync(body, signature, secret) (the async variant - the sync one relies on Node's synchronous crypto, which doesn't exist on workerd) and throws if the signature is missing or invalid. This is the only authentication on /api/payments/webhook.
#Webhook provisioning
Provision the webhook endpoint yourself using the Stripe SDK - stripe.webhookEndpoints.create({ url, enabled_events }) where url is ${baseUrl}/api/payments/webhook. Store the returned signing secret as STRIPE_WEBHOOK_SECRET on the deployed worker so StripePaymentProvider.parseWebhook can verify incoming payloads.
Enable the events below. They are the ones the provider maps to a status; any other event type reaches PaymentService unmapped, and is logged and ignored.
| Stripe event | Status |
|---|---|
payment_intent.succeeded |
captured |
payment_intent.amount_capturable_updated |
authorized |
payment_intent.payment_failed |
failed |
payment_intent.canceled |
failed |
checkout.session.completed |
captured when payment_status is paid; otherwise ignored |
checkout.session.async_payment_succeeded |
captured (SEPA debit, bank transfers and other delayed methods) |
checkout.session.async_payment_failed |
failed |
checkout.session.expired |
failed |
A Checkout event carries both the session id and its PaymentIntent id, and PaymentService.handleParsedWebhook() matches the intent on either. That matters on connected accounts, where Stripe creates the PaymentIntent lazily and the intent first stores the session id (cs_...).
parseConnectWebhook(request) verifies an event from a connect: true endpoint with STRIPE_CONNECT_WEBHOOK_SECRET, and returns the emitting account in account. No built-in route calls it: mount your own action that passes its result to PaymentService.handleParsedWebhook().
Earlier versions shipped an AlephaCliPlatformStripePlugin that registered a PlatformHook to do this during alepha platform up. That mechanism was removed: deploy frequency and webhook lifetime are different concerns. Webhook setup happens once per environment, not on every deploy - handle it from your provisioning code.
#Customer mapping
StripePaymentProvider caches a mapping of Alepha userId → Stripe customer ID in the database cache (alepha/cache/database, TTL 30 days), keyed per account (platform or the connected account id). On a cache miss it searches Stripe by metadata.alepha_user_id and creates a new customer if none is found, with an idempotency key so two concurrent misses cannot create two customers.
#Checkout sessions
createSession opens a Stripe Checkout session in mode: "payment" with one line item named after label ("Payment" when absent), and sends the payer back to returnUrl whether they pay or cancel. authorize: true sets capture_method: "manual", and saveCard: true sets setup_future_usage: "off_session". The stored providerRef is the session's PaymentIntent id, or the session id when Stripe has not created the PaymentIntent yet.
retrieveSessionStatus asks Stripe for that session or PaymentIntent, so the stale-intent sweep settles a payment whose webhook never arrived. expireSession expires an open Checkout session, or cancels the PaymentIntent (cancellation_reason: "abandoned").
#Saved payment methods
Stripe's tokenize-then-attach model maps directly: the client tokenizes a card via Stripe.js, posts the token to POST /api/payments/payment-methods, and StripePaymentProvider.createPaymentMethod calls paymentMethods.attach(token, { customer }).
#Beyond the contract
StripePaymentProvider also exposes Stripe-only helpers outside the PaymentProvider contract, reached by injecting StripePaymentProvider itself: Connect (createConnectAccount, getConnectAccount, getConnectAccountStatus, createAccountSession, createAccountOnboardingLink, parseConnectWebhook), subscriptions (createCheckoutSubscription, retrieveSubscription, updateSubscriptionPrice, setSubscriptionCancelAt, cancelSubscription, createBillingPortalSession), createCheckoutSetup and constructPlatformEvent. They are how an application lets Stripe own recurring billing, as the Payments guide recommends.
#Writing your own provider
Implement the contract and register it the same way. Both shipped providers (MemoryPaymentProvider and StripePaymentProvider) use implements rather than extends - note that with implements, the normally-optional retrieveSessionStatus becomes required, which is a feature: it forces the reconciliation path to exist.
1import { $module } from "alepha"; 2import { AlephaApiPayments, PaymentProvider } from "alepha/api/payments"; 3 4class AdyenPaymentProvider implements PaymentProvider { 5 // ... implement the lifecycle methods ... 6} 7 8export const AlephaPaymentsAdyen = $module({ 9 name: "alepha.payments.adyen",10 services: [AdyenPaymentProvider],11 imports: [AlephaApiPayments],12 register: (alepha) =>13 alepha.with({ provide: PaymentProvider, use: AdyenPaymentProvider }),14});
Three things to get right:
parseWebhookmust establish authenticity: either signature verification or re-fetch. The webhook endpoint has no other auth.- Status mapping is your contract with
PaymentService. The service understandsauthorized,captured,failed. Anything else is logged and ignored - use that to silently drop transient states (open,pending). When the PSP has two ids for one payment, return the second asproviderRefAlt: the service matches the intent on either and repoints it to the second. - Amounts are integers in Alepha's storage (minor units / cents). PSPs that want decimal strings (Mollie) need a converter.