alepha@docs:~/framework/docs/guides/features$
cat 6-payment-providers.md | pretty
6 min read
Last commit:

#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:

typescript
 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:

typescript
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.

Flowchart diagramdefaultAlephaPaymentsStripeyour modulePaymentServicePaymentProviderPaymentMethodServiceMemoryPaymentProviderStripePaymentProviderYour provider

#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.

typescript
 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:

  1. parseWebhook must establish authenticity: either signature verification or re-fetch. The webhook endpoint has no other auth.
  2. Status mapping is your contract with PaymentService. The service understands authorized, 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 as providerRefAlt: the service matches the intent on either and repoints it to the second.
  3. Amounts are integers in Alepha's storage (minor units / cents). PSPs that want decimal strings (Mollie) need a converter.