Routing
How a payment method becomes a gateway — the driver contract, the manager's three resolution rules, the capability checks that fail early, and why the SDKs load lazily.
The customer picks the method. Your code does not know at write time whether this charge will be a
Pix QR code or a card, and it should not have to. config.methods maps each canonical method to a
provider, and the manager resolves it:
methods: {
pix: 'woovi',
credit_card: 'stripe',
boleto: 'asaas',
debit_card: 'asaas',
undefined: 'asaas', // the customer chooses at checkout
}getPayments().driver('pix') // → the Woovi driver
getPayments().driver('stripe') // → by provider name
getPayments().driver() // → config.defaultThe three resolution rules
driver(methodOrName?) answers in this order:
- No argument → the provider named by
config.default, or the first configured one — andconfig.methodsis still consulted, see below. - A configured provider name → that driver, directly. No method check applies, because you did not route a method, and the routing map is deliberately not read: naming the provider already answered the question the map answers.
- Otherwise, a payment method → the provider
config.methodsroutes it to, and a check that the provider actually supports that method, and the method itself, threaded into the charge.
The third part of rule 3 matters more than it looks. Routing picks the provider; it also has to tell
the provider what it routed. driver('pix') hands back the driver bound to pix, so a charge
made through it is created as Pix without you repeating yourself:
getPayments().driver('pix').charge({ amount: 1990 })
// reaches the driver as { amount: 1990, method: 'pix' }Every driver that varies by method reads it off the charge — Stripe's payment_method_types, Asaas'
and AbacatePay's billingType — so before this, a charge routed as Pix was created with whatever the
gateway's dashboard defaults are. It read as working and could come back a card.
An explicit method on the charge still wins, unless the routing map sends that method somewhere
else, in which case the call is refused rather than put through the wrong gateway. Routing is a
default, not an override, and never a contradiction. A driver resolved by name comes back
untouched — driver('stripe') routed nothing, so there is nothing to thread.
driver() with no argument
The bare call consults config.methods too. Skipping the map is the same trap one level up: an app
that configures pix/credit_card/boleto routing and then calls driver() would get a driver bound to
nothing, and every charge would fall back to the gateway dashboard's default unless the caller
happened to repeat method:. Three cases:
-
Exactly one method routes to the default provider → it is applied, the same binding
driver('pix')would have produced. -
Several do → there is no honest answer.
methodssays this provider takes pix and boleto and card, and picking one would be inventing the charge's payment method.charge()andcreateSubscription()refuse, naming the two ways to say what you meant:[payments] driver() resolved "asaas", and config.methods routes pix, boleto to it — so this charge has no payment method and would fall back to whatever the gateway dashboard defaults to. Name it: driver('pix') or charge({ method: 'pix' }). -
No method routes there at all → nothing to bind, nothing to check, driver returned as-is.
The refusal is at charge/createSubscription, not at driver(), on purpose: an app that already
passes method: on every call — which is what makes it correct today — keeps working unchanged, and
so does every other method on the driver.
Anything that matches none of the three throws immediately, naming what is configured:
[payments] "pagseguro" is neither a configured provider nor a method routed in
config.methods. Configured providers: stripe, asaas. Known methods: pix,
credit_card, debit_card, boleto, undefined.That is the design goal for the whole routing layer: a misconfiguration should fail at the manager,
in your own stack trace, with the fix in the message — not three network hops later as a 400 from
someone else's API.
The contract every gateway implements
interface PaymentsDriver {
readonly provider: string
readonly supportedMethods: readonly PaymentMethodName[]
readonly capabilities?: {
disputes?: boolean
refunds?: boolean
invoices?: boolean
subscriptions?: boolean
}
// Whether this driver can authenticate a webhook delivery, and whether it was given what
// it needs to. 'unconfigured' refuses the boot; absent reads as 'unsupported'.
readonly webhookVerification?: 'configured' | 'unconfigured' | 'unsupported'
createCustomer(input): Promise<Customer>
findCustomer(customerId): Promise<Customer | null>
updateCustomer(customerId, input): Promise<Customer>
charge(input): Promise<Payment>
findPayment(gatewayId): Promise<Payment | null>
refund(paymentGatewayId, amount?, options?: { idempotencyKey?: string }): Promise<Refund>
createCheckout(input): Promise<CheckoutSession>
createSubscription(input): Promise<Subscription>
cancelSubscription(gatewayId, options?): Promise<Subscription>
updateSubscription(gatewayId, input): Promise<Subscription>
findSubscription(gatewayId): Promise<Subscription | null>
listInvoices(customerId): Promise<Invoice[]>
// Optional — a gateway can settle money perfectly and give you nothing but an email
// when a chargeback lands. Gated by `capabilities.disputes`.
findDispute?(disputeGatewayId): Promise<Dispute | null>
submitDisputeEvidence?(disputeGatewayId, evidence): Promise<Dispute>
parseWebhook(rawBody, headers):
| WebhookEvent
| WebhookEvent[]
| Promise<WebhookEvent | WebhookEvent[]>
}Three details in parseWebhook are worth naming, because they are each a bug somebody shipped: it
may return several events (Adyen's notificationItems, Efí's pix), it may be async (a
Mollie callback is a bare payment id, and the fetch that resolves it is also what authenticates it),
and every event it returns must carry an id of its own — the ledger keys on that id, so a batch
that reuses one makes the second event look like a redelivery of the first.
Every method returns a normalized domain type — Payment, Subscription, Invoice — with the
gateway's untouched response tucked into payload. Application code reads the normalized fields;
payload is the escape hatch for the one gateway-specific field you occasionally need.
Three declarations that fail early
supportedMethods, capabilities and webhookVerification exist so a limitation is discovered at
the boundary rather than at the gateway.
supportedMethods is checked when routing resolves a method:
[payments] Driver "abacate" does not support payment method "credit_card".
Supported methods: pix, boleto, undefined.
Route "credit_card" to a different provider in config.methods.capabilities is checked before delegating an operation that not every gateway has —
'refunds', 'invoices', 'subscriptions' or 'disputes':
const driver = payments.driver('pix')
payments.assertCapability(driver, 'refunds') // throws on Woovi/OpenPix — no refunds
await driver.refund(payment.gatewayId)For the three core capabilities a driver missing one still implements the method — the interface
demands it — and throws a clear "not supported" error. assertCapability surfaces that one step
earlier, which is what payments:sync and payments:webhook use to avoid advertising events a
gateway will never send.
disputes is the exception, and deliberately: findDispute and submitDisputeEvidence are
optional members, so a gateway with no dispute API omits them rather than implementing a throw.
Test for them the way you would any optional member — typeof driver.findDispute === 'function' —
and note that this is why a method-bound driver is a Proxy rather than a wrapper object: a wrapper
that defined every method would turn "this gateway cannot do that" into "it can, until you call
it".
Capabilities are opt-in, so check rather than assume
A driver that declares no capabilities object is treated as supporting none of them. That is
deliberately conservative: an undeclared capability is an unproven one.
webhookVerification is checked once, at boot, before the webhook route goes up. A driver
reporting 'unconfigured' — it can authenticate a delivery and was given no credential — throws,
unless the provider is named in allowUnverifiedWebhooks. The same conservatism applies: the field
is optional so a driver outside this package keeps compiling, and absent reads as
'unsupported', because a driver that never opted in cannot be assumed to verify.
Providers are lazy factories
providers maps a name to a factory, not to an instance:
providers: {
stripe: payments.stripe({ apiKey: env.get('STRIPE_KEY'), currency: 'brl' }),
woovi: payments.woovi({ appId: env.get('WOOVI_APP_ID') }),
}Each built-in factory imports its gateway SDK inside the thunk. Configure four gateways and charge only Pix through Woovi, and the Stripe SDK is never loaded into memory. That is what lets every gateway SDK be an optional peer dependency: you install the ones you configure, and you pay the import cost only for the ones you call.
It is also why a custom driver is a factory rather than a class — see Custom providers.
Routing in practice
The common Brazilian setup splits by method, because each gateway is genuinely better at something:
export default defineConfig({
default: 'asaas',
providers: {
woovi: payments.woovi({ appId: env.get('WOOVI_APP_ID') }),
stripe: payments.stripe({ apiKey: env.get('STRIPE_KEY'), currency: 'brl' }),
asaas: payments.asaas({ apiKey: env.get('ASAAS_API_KEY') }),
},
methods: {
pix: 'woovi', // best Pix rates
credit_card: 'stripe', // best card tooling
boleto: 'asaas',
debit_card: 'asaas',
undefined: 'asaas',
},
})The service layer that consumes this never names a gateway:
const payment = await getPayments()
.driver(order.method) // 'pix' | 'credit_card' | 'boleto'
.charge({ customerId, amount: order.total, externalReference: order.id })Note what is not there: method: order.method repeated on the charge. Routing carries it.
Moving Pix from Woovi to Asaas is one line of config, and this service does not change.
Money
Why every amount is an integer of the smallest currency unit, where the decimal conversion lives, how currency is resolved, and the arithmetic rules that keep a bill from being off by a cent.
The payment lifecycle
From charge to revenue — what a PENDING payment actually is, the six things the webhook route does in order, the ten normalized event types, and how externalReference routes a confirmation back to your row.