alepha@docs:~/docs/framework/guides/payments$
cat 2-providers.md | pretty
4 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 eight required methods covering the full intent lifecycle, plus two a real provider should implement:

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

typescript
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

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

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).
  3. Amounts are integers in Alepha's storage (minor units / cents). PSPs that want decimal strings (Mollie) need a converter.