Agora
Concepts

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:

config/payments.ts
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.default

The three resolution rules

driver(methodOrName?) answers in this order:

  1. No argument → the provider named by config.default, or the first configured one — and config.methods is still consulted, see below.
  2. 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.
  3. Otherwise, a payment method → the provider config.methods routes 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. methods says this provider takes pix and boleto and card, and picking one would be inventing the charge's payment method. charge() and createSubscription() 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 typePayment, 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:

config/payments.ts
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:

app/services/checkout_service.ts
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.

On this page