#Payment Providers
A PaymentProvider is the bridge between Alepha's lifecycle and a real payment service provider. The abstract class defines eight required methods covering the full intent lifecycle, plus two a real provider should implement:
1abstract class PaymentProvider { 2 createSession(intent, { returnUrl, authorize, ... }): 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<{ providerRef, status, raw }>; 7 createPaymentMethod(userId, token): Promise<CreatePaymentMethodResult>; 8 deletePaymentMethod(providerRef): Promise<void>; 9 expireSession(providerRef, options?): Promise<void>;10 11 // optional: embedded card fields (Stripe Payment Element and friends);12 // PaymentService.supportsEmbeddedPayment() dispatches on its presence13 createElementSession?(intent, options): Promise<{ clientSecret, publishableKey, provider, providerRef }>;14 15 // non-abstract, returns null by default - but providers SHOULD override it:16 // it is the reconciliation path when a webhook goes missing. The shipped17 // provider does.18 retrieveSessionStatus(providerRef, options?): Promise<SessionStatus | null>;19}
createSession options also carry Connect-style fields (stripeAccount, applicationFeeAmount, customerEmail). Every later call about that session takes the same { stripeAccount } (ProviderAccountOptions): 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.
One implementation ships with the framework, @alepha/payments-stripe. It composes with AlephaApiPayments like this:
1import { AlephaApiPayments } from "alepha/api/payments";2import { AlephaPaymentsStripe } from "@alepha/payments-stripe";3 4const alepha = Alepha.create()5 .with(AlephaApiPayments)6 .with(AlephaPaymentsStripe);
The provider module declares register: alepha.with({ provide: PaymentProvider, use: ... }) - the MemoryPaymentProvider default is overridden automatically.
#Stripe
yarn add @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 |
Required for the embedded Payment Element - createElementSession throws without it. |
STRIPE_CONNECT_WEBHOOK_SECRET |
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.
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 (TTL 30 days). On a cache miss it searches Stripe by metadata.alepha_user_id and creates a new customer if none is found.
#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 }).
#Writing your own provider
Implement the contract and register it the same way. Both shipped providers 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). - Amounts are integers in Alepha's storage (minor units / cents). PSPs that want decimal strings (Mollie) need a converter.