Payment Providers
Every payment integration in @akira-io/payable is reduced to a single interface: PaymentProvider.
The engine never talks to Stripe or Paddle directly. It talks to the contract, and concrete adapters
translate domain DTOs into provider SDK or API calls and provider webhooks back into domain events. This keeps
the application and domain layers provider-agnostic and makes a new integration a matter of implementing
one interface.
The contract lives in src/domain/contracts/payment-provider.contract.ts.
The PaymentProvider contract
PaymentProvider is deliberately small. It declares only the surface that every provider can
honour, regardless of its business model. A subscription/SaaS gateway (Stripe, Paddle) and a one-time
hosted-redirect gateway (SISP) both satisfy it. All methods that reach a provider API are asynchronous
and receive an OperationContext (ctx) carrying the idempotency key.
| Member | Input | Output |
|---|---|---|
name (readonly property) | - | string - the registry key, e.g. 'stripe' |
capabilities() | - | ProviderCapabilities |
createCheckoutSession(input, ctx) | CreateCheckoutSessionInput | Promise<CheckoutSessionDTO> |
refund(input, ctx) | RefundInput | Promise<RefundResultDTO> |
Everything else - customers, catalog, subscription management, webhooks, the billing portal - is an optional capability interface. A provider implements only the ones it genuinely supports, and the engine narrows to them at runtime with a type guard before calling. This is why SISP (which has no customers API, no catalog, no subscriptions, and no asynchronous signed webhook) can be a first-class provider: it implements the slim core plus the one optional interface that fits its redirect flow.
Optional capability interfaces
Catalog capability interfaces are declared in src/domain/contracts/catalog-provider.contract.ts.
src/domain/contracts/payment-provider.contract.ts retains their type and guard re-exports for
compatibility. The remaining optional interfaces are declared in
src/domain/contracts/payment-provider.contract.ts. Each interface ships with a structural isXCapable
guard (duck-typing on method presence). Calling code narrows first, then either calls the method or throws
ProviderCapabilityNotSupportedError.
| Interface | Method(s) | Guard |
|---|---|---|
CustomerCapable | createCustomer(input, ctx), updateCustomer(input, ctx) | isCustomerCapable(provider) |
CatalogCapable | createProduct, updateProduct, createPrice | isCatalogCapable(provider) |
CatalogProductCreateCapable | createProduct | isCatalogProductCreateCapable(provider) |
CatalogProductUpdateCapable | updateProduct | isCatalogProductUpdateCapable(provider) |
CatalogPriceCreateCapable | createPrice | isCatalogPriceCreateCapable(provider) |
CatalogPriceUpdateCapable | updatePrice | isCatalogPriceUpdateCapable(provider) |
CatalogReadCapable | retrieveProduct, listProducts, retrievePrice, listPrices | isCatalogReadCapable(provider) |
CatalogLifecycleCapable | setProductActive, setPriceActive | isCatalogLifecycleCapable(provider) |
PriceLookupKeyCapable | keyed createPrice, keyed listPrices, transferPriceLookupKey | isPriceLookupKeyCapable(provider) |
SubscriptionManagementCapable | updateSubscription, cancelSubscription, resumeSubscription | isSubscriptionManagementCapable(provider) |
WebhookCapable | verifyWebhook(input), reconcileSubscription(verified) | isWebhookCapable(provider) |
PaymentWebhookCapable | reconcilePayment(verified) | isPaymentWebhookCapable(provider) |
BillingPortalCapable | billingPortal(input, ctx) | isBillingPortalCapable(provider) |
RedirectCallbackCapable | verifyCallback(payload), handleRedirectCallback(payload) | isRedirectCallbackCapable(provider) |
ChargeCapable | charge(input, ctx) | isChargeCapable(provider) |
DirectSubscriptionCapable | createSubscription(input, ctx) | isDirectSubscriptionCapable(provider) |
InvoiceCapable | listInvoices(input), downloadInvoicePdf(id) | isInvoiceCapable(provider) |
PaymentMethodCapable | listPaymentMethods(input), deletePaymentMethod(input, ctx) | isPaymentMethodCapable(provider) |
PaymentMethodSetupCapable | createPaymentMethodSetup(input, ctx), retrievePaymentMethodSetup(id), cancelPaymentMethodSetup(id, ctx) | isPaymentMethodSetupCapable(provider) |
DisputeCapable | listDisputes(input), retrieveDispute(id), acceptDispute(id, ctx) | isDisputeCapable(provider) |
PayoutCapable | listPayouts(input), retrievePayout(id) | isPayoutCapable(provider) |
ProviderWebhookEndpointManagementCapable | provider webhook endpoint CRUD with bounded listing | isProviderWebhookEndpointManagementCapable(provider) |
Notes on the non-obvious members:
verifyWebhook(onWebhookCapable) takes noctx. Its input is{ payload, signature, headers? }and it returns aVerifiedWebhookwithproviderEventId, rawtype, the engine’snormalizedType, and the eventdata. A failed signature throwsInvalidWebhookSignatureError.reconcileSubscriptionis synchronous and pure. Given an already-verified webhook it returns aSubscriptionDTOwhen the normalized type starts withsubscription., otherwisenull.PaymentWebhookCapableis separate fromWebhookCapable. It lets hosted-checkout providers map an already-verified payment webhook to{ providerPaymentId, status }without forcing every webhook-capable provider to implement payment reconciliation.PaymentMethodSetupCapablemanages the setup lifecycle independently from charging. Its normalized result can expose a client secret, a hosted checkout URL, or the resulting provider payment method ID without exposing vendor SDK types.RedirectCallbackCapablemodels a synchronous browser-POST callback (SISP), not an asynchronous signed webhook.handleRedirectCallbackreturns a normalized{ providerPaymentId, status }the engine uses to reconcile a local payment. See SISP.
Narrowing helper
For capabilities that are also declared in the ProviderCapabilities set and backed by optional
interfaces, the engine uses assertCapableProvider
(src/application/services/provider-capabilities/assert-provider-capability.ts), which checks the set
and narrows the type in one step:
assertCapableProvider(provider, 'customers', isCustomerCapable);
// provider is now PaymentProvider & CustomerCapable
Some provider features are represented both ways: a known capability string for honest feature
advertising and an optional interface for the callable methods. Examples include customers
(CustomerCapable), invoicePdf (InvoiceCapable), charges (ChargeCapable), and webhooks
(WebhookCapable). Catalog creation, reads, and lifecycle changes use catalog, catalogRead, and
catalogLifecycle with their matching structural guards. Redirect callbacks remain guard-only because
they model a provider-specific browser callback flow, not an asynchronous provider webhook.
PriceLookupKeyCapable is narrower than the ordinary catalog capabilities. It supports a create
with lookupKey, create-time transferLookupKey: true, list filtering with lookupKeys, and explicit
prices().transferLookupKey({ providerPriceId, lookupKey }, options). The engine gates those calls
with priceLookupKeys and isPriceLookupKeyCapable; normal catalog operations do not require it.
Capability matrix
The matrix describes support implemented by each built-in Payable adapter, not every feature offered
by the external provider. A no cell only means that this adapter does not expose the operation.
| Capability | Stripe | Paddle | SISP | Revolut |
|---|---|---|---|---|
checkout | yes | yes | yes (redirect form) | yes (amount order, subscription setup order) |
refunds | yes | yes | no | yes (amount required) |
customers | yes | yes | no (local-only customers) | yes |
catalog | yes | yes | no | no |
catalogRead | yes | yes | no | no |
catalogLifecycle | yes | yes | no | no |
catalogIdempotency | yes | no | no | no |
catalogProductCreate | yes | yes | no | no |
catalogProductUpdate | yes | yes | no | no |
catalogProductArchive | yes | yes | no | no |
catalogProductReactivate | yes | yes | no | no |
catalogPriceCreate | yes | yes | no | no |
catalogPriceUpdate | yes | yes | no | no |
catalogPriceArchive | yes | yes | no | no |
catalogPriceReactivate | yes | yes | no | no |
priceLookupKeys | yes | no | no | no |
subscriptions | yes | yes | no | yes (limited) |
trials | yes | no | no | no |
coupons | yes | no | no | no |
billingPortal | yes | yes | no | no |
webhooks (WebhookCapable) | yes | yes | no (uses redirect callback) | yes |
PaymentWebhookCapable | yes | no | no | yes |
RedirectCallbackCapable | no | no | yes | no |
charges (ChargeCapable) | yes | no | no | no |
invoicePdf (InvoiceCapable) | yes | no | no | no |
paymentMethods (PaymentMethodCapable) | yes | no | no | yes |
paymentMethodSetup (PaymentMethodSetupCapable) | yes | no | no | yes |
disputes (DisputeCapable) | yes | no | no | yes (production only) |
payouts (PayoutCapable) | yes | no | no | yes |
webhookEndpointManagement | yes | no | no | yes |
Canonical catalogue synchronization uses the granular rows rather than inferring one operation from
another. catalogRead supports reconciliation and catalogIdempotency enables safe automatic retry.
See Catalog Lifecycle for explicit synchronization, retry, and reconciliation examples. Provider API background: Stripe Products, Stripe Prices, Stripe idempotency, Stripe webhooks, Paddle Products, Paddle Prices, and Paddle notifications.
The capabilities system
A provider declares the feature set it supports through capabilities(), which returns a
ProviderCapabilities (src/domain/dtos/capabilities.dto.ts):
export type ProviderCapability =
| 'checkout'
| 'charges'
| 'subscriptions'
| 'trials'
| 'refunds'
| 'coupons'
| 'billingPortal'
| 'meteredBilling'
| 'invoicePdf'
| 'webhooks'
| 'customers'
| 'paymentMethods'
| 'paymentMethodSetup'
| 'disputes'
| 'payouts'
| 'webhookEndpointManagement'
| 'catalog'
| 'catalogRead'
| 'catalogLifecycle'
| 'priceLookupKeys';
export type ProviderCapabilityValue = ProviderCapability | (string & {});
export type ProviderCapabilities = ReadonlySet<ProviderCapabilityValue>;
It is a set, not a fixed matrix. A provider lists only what it supports; an absent capability means
unsupported (opt-in by presence). ProviderCapabilityValue is the union of known capabilities plus an
open string arm, so a provider may declare custom capabilities the core does not know about (for
example 'x-acme-dunning') while the known names keep autocomplete. Adding a new core capability is a
new union member, not a new required field, so it does not break existing custom providers.
Subscription operation capabilities
The coarse subscriptions capability says that a provider participates in subscription workflows.
It does not imply that every lifecycle operation or policy is available. Providers can implement the
optional SubscriptionOperationCapabilitiesProvider contract to publish a serializable descriptor:
const operations = payable
.providers()
.subscriptionOperationCapabilities('stripe');
operations.create.direct;
operations.itemIdentity;
operations.changePrice.effectiveTimings;
operations.cancel.atPeriodEnd;
The descriptor separates creation, price changes, quantity changes, cancellation, pause, and resume. Change capabilities list supported effective timings, proration policies, and payment-failure policies. Pause and resume capabilities list scheduling and billing-cycle behavior. The returned snapshot and its policy arrays are immutable.
itemIdentity is stable for Stripe, price for Paddle, and none for providers that do not expose
addressable subscription items. This lets consumers require an explicit local item and avoid assuming
that the first provider item is primary.
| Subscription operation | Stripe | Paddle | SISP | Revolut |
|---|---|---|---|---|
| Hosted checkout creation | yes | yes | no | yes |
| Direct creation | yes | no | no | yes |
| Price change timing | immediate | immediate | no | next renewal |
| Price change proration | immediate, next invoice, none | immediate, next invoice, full charge immediately, full charge at next renewal, none | no | none |
| Price change payment failure | prevent change, apply change | prevent change, apply change | no | apply change |
| Quantity change | immediate | immediate | no | no |
| Preview change | yes | yes | no | yes |
| Cancel immediately | yes | yes | no | yes |
| Cancel at period end | yes | yes | no | no |
| Pause subscription timing | no | immediate, next renewal | no | no |
| Pause scheduled resume | no | yes | no | no |
| Pause resume billing policy | no | new billing period, existing billing period | no | no |
| Pause payment collection | keep as draft, mark uncollectible, void | no | no | no |
| Payment collection scheduled resume | yes | no | no | no |
| Resume pending cancellation | yes | no | no | no |
| Resume paused subscription timing | no | immediate, scheduled | no | no |
| Resume paused subscription billing | no | new billing period, existing billing period | no | no |
| Resume payment collection | yes | no | no | no |
| Cancel scheduled change | no | yes | no | no |
This matrix describes the current Payable adapters, not every feature offered by the external providers. Lifecycle pause and payment-collection pause are deliberately separate operations. Stripe payment-collection pause leaves the subscription lifecycle status unchanged; Paddle lifecycle pause changes the subscription state. SISP and Revolut advertise neither operation.
Stripe and Paddle return provider-calculated monetary previews. Revolut has no monetary change-plan
preview endpoint, so Payable returns a structural next-renewal preview with unknown amounts as
null. Apply always reuses the exact stored preview input and provider calculation timestamp.
Built-in providers publish explicit descriptors. A custom provider can add the optional method
without changing its existing capabilities() implementation. For legacy providers that do not
implement the method, registry discovery returns a conservative creation-only descriptor inferred
from the coarse capability and direct-creation interface. Existing legacy operations are not blocked
by granular assertions, which preserves compatibility while integrations migrate to explicit
descriptors.
The coarse subscriptions value remains supported for family-level discovery and existing guards,
but it is deprecated as a source for operation-level decisions. Migration is additive: implement
subscriptionOperationCapabilities(), move user-interface and workflow checks to the descriptor,
then retain subscriptions while supporting current Payable releases. No removal release is
scheduled.
Before a built-in provider call, Payable asserts the requested operation and throws
ProviderCapabilityNotSupportedError with a stable capability name such as
subscriptions.change-quantity or subscriptions.cancel.at-period-end. The assertion runs before
customer synchronization or other provider side effects.
This is distinct from the optional interfaces above. The interfaces answer “does this method exist?”;
ProviderCapabilities answers “does the provider claim to support this feature?”. The engine guards a
declared capability with assertProviderCapability
(src/application/services/provider-capabilities/assert-provider-capability.ts):
export function assertProviderCapability(
provider: PaymentProvider,
capability: ProviderCapabilityValue,
): void {
if (!provider.capabilities().has(capability)) {
throw new ProviderCapabilityNotSupportedError(provider.name, capability);
}
}
When the capability is absent from the set, it throws ProviderCapabilityNotSupportedError
(src/domain/errors/provider-capability-not-supported.error.ts) with code
PROVIDER_CAPABILITY_NOT_SUPPORTED and a message of the form
Provider '<name>' does not support capability: <capability>. The error context carries
{ provider, capability }.
Tax providers
Tax calculation and transaction recording use the independent TaxProvider family. Configure
adapters through taxProviders and retrieve them with payable.taxProviders(). The registry is empty
when omitted and throws TaxProviderNotFoundError for unknown names.
TaxCalculationCapable calculates and retrieves normalized tax calculations.
TaxTransactionCapable commits and reverses tax transactions. Tax providers are not payment
providers, and no tax adapter is registered automatically.
Issuing providers
Card issuing uses the independent IssuingProvider family and issuingProviders configuration.
Capabilities cover cardholders, cards, authorizations, and issuing transactions independently. Every
operation requires its matching structural guard, and no built-in adapter is registered by default.
Normalized card DTOs expose only non-sensitive display metadata. Full card numbers, CVV, PIN, track data, and provider secrets are excluded from the contracts.
Marketplace providers
Seller account coordination uses MarketplaceProvider and the optional marketplaceProviders
configuration. Accounts, onboarding, transfers, and payouts are separate capabilities with complete
runtime guards. Payment acceptance remains the responsibility of payment providers.
- Purpose: fail fast and explicitly before reaching the provider API for an unsupported operation.
- Edge case: a provider may also throw
ProviderCapabilityNotSupportedErrorfrom inside a method for a partial limitation. Paddle does this for partial refunds (see the Paddle integration page).
Known capabilities gate their matching resource operations before Payable calls a provider:
customersguardspayable.customers().create(...)and.update(...). A read with.get(...)comes from local storage and is not gated. Provider-backed customer sync also requires thecustomerscapability before callingcreateCustomerorupdateCustomer. If a stored customer has a provider customer id and the provider declarescustomers, update requires the fullCustomerCapableinterface instead of silently falling back to local-only changes.catalogguardspayable.providerCatalog().products.create(...),payable.providerCatalog().products.update(...), andpayable.providerCatalog().prices.create(...).catalogReadguards product and priceretrieve(...)andlist(...)operations.catalogLifecycleguards product and priceactivate(...)andarchive(...)operations.priceLookupKeysguards price creates carryinglookupKeyortransferLookupKey: true, price lists carryinglookupKeys, andprices().transferLookupKey(...). Providers without an official equivalent do not advertise it.subscriptionsguards subscription management. Direct subscription creation also requires this declared capability before storage or provider calls, but a provider may still omitDirectSubscriptionCapableand support subscription creation only through checkout.chargesguards direct charge creation before the provider is called.invoicePdfguards invoice listing and PDF download before invoice provider methods are used.webhooksguards webhook receipt before signature verification is delegated to the provider. Replay and subscription reconciliation also treat providers without this declared capability as stored-event-only, even if a provider object happens to expose webhook-shaped methods.
A provider whose set omits a required capability rejects the corresponding operation with
PROVIDER_CAPABILITY_NOT_SUPPORTED (HTTP 422) before any network call.
The provider registry
ProviderRegistry (src/payable.ts) is a thin Map<string, PaymentProvider> wrapper:
| Method | Behavior |
|---|---|
register(name, provider) | Stores a provider under name. |
get(name) | Returns the provider, or throws ProviderNotFoundError (PROVIDER_NOT_FOUND) when absent. |
has(name) | true when a provider is registered under name. |
names() | Registered provider names, in insertion order. |
subscriptionOperationCapabilities(name) | Returns an immutable granular subscription descriptor or a conservative legacy fallback. |
The registry is built from the resolved config and exposed via payable.providers().
Provider selection and ambiguity
Selection rules:
- Passing a name targets it explicitly:
payable.customer(billable, 'secondary')routes to thesecondaryprovider. - Omitting the name defaults to the first registered provider:
names()[0]. - An unknown name throws
ProviderNotFoundError.
Webhook routing has a stricter ambiguity rule (Payable.defaultWebhookProvider in src/payable.ts):
when more than one provider is registered and the incoming webhook does not name a provider, the engine
throws PayableError with code WEBHOOK_PROVIDER_AMBIGUOUS and the message
Multiple providers are registered; route the webhook to /webhooks/:provider. With a single provider it
is inferred.
flowchart TD Engine[Payable engine] --> Registry[ProviderRegistry] Registry -->|get name| Contract[PaymentProvider contract] Contract -. implemented by .-> Stripe[StripeProvider] Contract -. implemented by .-> Paddle[PaddleProvider] Contract -. implemented by .-> Sisp[SispProvider] Contract -. implemented by .-> Custom[Your provider] Stripe --> StripeAPI[Stripe SDK] Paddle --> PaddleAPI[Paddle SDK] Sisp --> SispPkg["@akira-io/sisp (node-sisp)"] Revolut --> RevolutAPI[Revolut Merchant API]
Implementing a custom provider
A minimal provider implements the slim PaymentProvider core, declares its name and capabilities(),
and adds only the optional interfaces it genuinely supports. The implements list and the
capabilities() set must agree.
import type {
PaymentProvider,
CustomerCapable,
ChargeCapable,
} from '@akira-io/payable';
export class AcmeProvider implements PaymentProvider, CustomerCapable, ChargeCapable {
readonly name = 'acme';
capabilities() {
return new Set(['checkout', 'charges', 'refunds', 'customers', 'x-acme-dunning']);
}
async createCheckoutSession(input, ctx) { /* ...map to Acme hosted checkout... */ }
async refund(input, ctx) { /* ...map to Acme refund... */ }
// CustomerCapable
async createCustomer(input, ctx) {
const customer = await this.api.createCustomer(input.email, ctx.idempotencyKey);
return { providerCustomerId: customer.id, email: customer.email, name: customer.name ?? null };
}
async updateCustomer(input, ctx) { /* ... */ }
// ChargeCapable
async charge(input, ctx) {
const charge = await this.api.charge(input.amount.amount(), ctx.idempotencyKey);
return { providerPaymentId: charge.id, status: 'succeeded', amount: input.amount };
}
}
Constraints to honour:
namemust be a validProviderName(src/domain/value-objects/provider-name.ts): lower-case, matching^[a-z][a-z0-9_-]*$. It is also the registry key callers pass tocustomer(billable, name).- Implement
createCheckoutSessionandrefund(the required core) plus exactly the optional interfaces yourcapabilities()set claims. A guard narrows on method presence, so a declared capability whose method is missing fails at call time. verifyWebhook(ifWebhookCapable) must throwInvalidWebhookSignatureError(not a generic error) on a bad signature so the webhook pipeline can reject it cleanly.reconcileSubscriptionshould returnnullfor non-subscription events.PaymentWebhookCapable.reconcilePaymentshould returnnullfor non-payment events and should only return statuses representable by the domainPaymentStatusvalue object.- Keep
capabilities()honest. The engine trusts it to gate features; lying produces failures at the provider API instead of a cleanProviderCapabilityNotSupportedError.
Register it like any built-in provider through the engine config ({ providers: { acme: new AcmeProvider(...) } }).
Accounting integrations use the independent accountingProviders config and
payable.accountingProviders() registry. Their tax-rate metadata is for bookkeeping only and remains
separate from tax calculation providers. Categories, tax rates, labels, expense reads, full expense
management, and ledger access are advertised independently.
AccountingExpenseReadCapable is the normalized list and retrieve surface and uses the expenseReads
capability. AccountingExpenseCapable extends it with updates and requires expenses. A provider that
can only read expenses must not advertise the full capability. Applications should narrow with
isAccountingExpenseReadCapable when they do not need mutation.
Identity integrations use the independent identityProviders config and
payable.identityProviders() registry. Applications remain responsible for consent, retention,
access control, and legal compliance; provider adapters must not return raw identity evidence through
the normalized contracts.
Terminal integrations use the independent terminalProviders config and
payable.terminalProviders() registry. They discover devices and coordinate server-driven terminal
actions without extending PaymentProvider or adding device SDK dependencies to the core package.