Configuration
The config/payments.ts reference — named providers built with lazy factories, method routing with capability checks, the invoice section, and the billing layer (who owns the schema, how a webhook is processed, where business handlers live).
config/payments.ts answers four questions:
- Which gateways do I accept payments through? —
providers(a named map of driver factories). - Which gateway handles each payment method? —
methods(e.g.pix → woovi,credit_card → stripe). - Who emits my invoices? —
invoice(independent of the gateway). - How are webhooks processed, and where do the tables come from? —
billing.
configure publishes two more files, each with its own page: config/payments_dashboard.ts
(Dashboard) and config/payments_client.ts
(Client). Both are separate because they are separate authorization
surfaces — the dashboard mounts at /payments and the client status endpoint at
/payments/client, sharing that prefix with POST /payments/webhook/:provider. Nothing on this
page opens a route to a browser.
A minimal config is two blocks:
import env from '#start/env'
import { defineConfig, payments } from '@adonis-agora/payments'
export default defineConfig({
default: 'stripe',
providers: {
stripe: payments.stripe({ apiKey: env.get('STRIPE_KEY'), currency: 'brl' }),
},
})defineConfig is a typed identity — every option below is type-checked as you write it.
Providers — lazy driver factories
providers is a map of name → factory. The factory receives a context and returns a
driver instance. The important property: each built-in factory lazily imports its gateway
SDK inside the thunk, so a config with four gateways only loads the SDK of the one you
actually call. Stripe's SDK isn't in memory if you only charge Pix through Woovi.
providers: {
stripe: payments.stripe({ apiKey: env.get('STRIPE_KEY'), currency: 'brl' }),
asaas: payments.asaas({
apiKey: env.get('ASAAS_API_KEY'),
sandbox: env.get('NODE_ENV') !== 'production',
}),
woovi: payments.woovi({ appId: env.get('WOOVI_APP_ID') }),
},defaultnames the provider used when a call doesn't specify one.- Every driver is an optional peer: only the SDKs of the drivers you configure are installed, and only the ones you call are loaded.
Driver config options
A sample — the other twelve gateways document their own options on their provider page:
| Driver | Key config | Convenience env fallback |
|---|---|---|
| Stripe | apiKey, currency (required), webhookSecret | STRIPE_KEY, STRIPE_WEBHOOK_SECRET |
| AbacatePay | apiKey, webhookSecret/publicKey | ABACATE_API_KEY, ABACATE_PUBLIC_KEY |
| Asaas | apiKey, sandbox, webhookToken | ASAAS_API_KEY, ASAAS_WEBHOOK_ACCESS_TOKEN |
| Woovi | appId, webhookSecret, webhookPublicKey | WOOVI_APP_ID, WOOVI_WEBHOOK_SECRET, WOOVI_WEBHOOK_PUBLIC_KEY |
| PagBank | token, sandbox, webhookToken | PAGBANK_TOKEN, PAGBANK_WEBHOOK_TOKEN |
| Efí | clientId, clientSecret, pixKey, certificate | EFI_CLIENT_ID, EFI_CLIENT_SECRET, EFI_PIX_KEY, EFI_CERTIFICATE |
Every credential is a config value first — name your .env variables however you want
and reference them with env.get('YOUR_NAME'). The "env fallback" column is only what a
driver reads when you don't pass the value, so a bare payments.asaas() works with the
ecosystem's conventional names. It's a convenience default, not a contract: pass
apiKey/webhookToken/… explicitly and your naming wins.
The webhook credentials are what make the mounted route enforce signatures — see Webhooks → Validation.
allowUnverifiedWebhooks — the opt-out for a boot refusal
A driver that can authenticate a webhook and was given no credential refuses to boot. With an
empty slot there is nothing to check against, and POST /payments/webhook/:provider would accept
any body anyone posted to it — including one that marks a payment paid.
export default defineConfig({
providers: { efi: payments.efi({ /* ... */ }) },
allowUnverifiedWebhooks: ['efi'],
})| Value | Meaning |
|---|---|
| omitted (default) | any driver reporting 'unconfigured' throws at boot, naming the provider |
['efi', 'asaas'] | those providers may take unverified deliveries |
true | every provider may. Almost never what you want |
Set it only when verification genuinely happens upstream — mutual TLS at the edge, or an API
gateway that checks the signature before forwarding. Efí and InfinitePay never trip the check:
their gateways sign nothing, so they report 'unsupported' rather than 'unconfigured' and there
is no credential to forget.
This is a breaking change for an existing app
An app that has been running with, say, ASAAS_WEBHOOK_ACCESS_TOKEN unset will not boot after
upgrading. That is the point — it was accepting forged deliveries — but it is a deploy-time
failure, so set the credentials before you ship, not after.
Methods — routing a payment method to a provider
A charge doesn't always know which gateway will handle it — the customer picks the
method (Pix vs card). methods maps each canonical method to a provider name:
methods: {
pix: 'woovi', // Pix via Woovi/OpenPix
credit_card: 'stripe', // card via Stripe
boleto: 'asaas', // boleto via Asaas
debit_card: 'asaas',
undefined: 'asaas', // customer chooses at checkout
},Then getPayments().driver('pix') resolves to the routed provider. Four rules keep a
misconfiguration from failing at the gateway:
- Unknown method/provider → the manager throws with a helpful list of what's configured, instead of a confusing SDK error.
- Method not supported by the routed gateway → early throw. Routing
credit_cardto a Pix-only gateway (AbacatePay, Woovi) is a config error, and it's caught when you resolve the driver — not after a request to the gateway. The manager checksdriver.supportedMethodsbefore returning the driver. driver()with no argument consultsmethodstoo. Exactly one method routed to that provider is applied, the same bindingdriver('pix')would produce. Skipping the map would hand back a driver bound to nothing, and every charge would fall back to whatever the gateway dashboard defaults to.- Ambiguity is refused, not guessed. When
methodsroutes several methods to the providerdriver()resolved, there is no honest answer — socharge()andcreateSubscription()throw unless the call names amethod:, and the message names both ways to say what you meant. Every other method on the driver is untouched. An app that already passesmethod:everywhere is unaffected.
Once a driver was resolved through the routing map, a method: on the call that the map routes
elsewhere is refused rather than sent to the wrong gateway — driver('pix').charge({ method: 'credit_card' }) throws when methods.credit_card names another provider. Resolving by provider
name is the deliberate escape hatch: driver('stripe') says which gateway you mean, so
methods is not consulted at all and nothing is bound.
Invoice — independent of the gateway
A payment and its invoice are decoupled: the charge can pay through Asaas while the invoice is emitted through a dedicated provider like Focus or Tecnospeed.
invoice: {
default: 'focus',
providers: {
focus: invoice.focus({ token: env.get('FOCUS_NFE_TOKEN') }),
},
defaults: {
service: { description: 'Software license' },
},
}A charge then asks for emission with invoice: true (default provider) or a named one.
See Invoices for the full option surface.
Billing — the subscription layer
The Cashier-style billing layer (Lucid stores, mixins, idempotent webhook processing). When enabled, the provider wires the store, the processor and the dispatcher.
billing: {
enabled: true,
/** How a validated webhook is processed — name the backend you actually run. */
dispatcher: 'durable',
/**
* Business webhook handlers run inside the mounted route. A DI service class (the
* lib calls its `.handle(event)`) or a plain function.
*/
handlers: {
'payment.succeeded': PaymentSucceededHandler,
'payment.refunded': (event) => { /* ... */ },
},
}Every option, and what you get by omitting it:
| Option | Default | What it does |
|---|---|---|
enabled | true | false wires no store, processor or dispatcher. The webhook route still mounts and verifies, and then does nothing with what it verified. |
autoCreateSchema | true | The library creates its own tables on first use. See below. |
dispatcher | 'auto' | Which backend runs a validated webhook. See below. |
role | 'all' | 'api' mounts the route and never processes; 'worker' processes and never mounts. Both demand an explicit 'durable' dispatcher — there is no channel between the halves otherwise, and it is refused at boot. See Production → Splitting api and worker. |
store | the Lucid store over the billing tables | Where the layer persists. billingStores.lucid({ models }) swaps the models; a factory of your own replaces the store entirely. See Billing → The billing store. |
handlers | none | Your business logic, keyed by normalized event type. See below. |
passthroughEvents | none | Gateway event types your drivers pass through unmapped that you register a handler for, when the type happens to fall in the library's own payment.*/subscription.* namespace. See below. |
durable | — | Legacy alias for dispatcher. See the callout below. |
autoCreateSchema — who owns the DDL
true by default, and there is no migration to run: on its first query the Lucid store
creates the seven billing tables itself, the same convention @adonis-agora/durable and
@adonis-agora/authz follow. The DDL is idempotent (CREATE TABLE IF NOT EXISTS, plus guarded
ALTERs for columns added after a table shipped), so it costs one round trip per process and an
install that already ran an older migration gets no-ops.
billing: { autoCreateSchema: false }Turn it off when the schema is managed elsewhere — a shared database you do not own, a team that
reviews every DDL, a deploy that runs migrations as their own step. Then run the migration
configure published:
node ace migration:runThat file calls the same exported createBillingTables, so the two paths cannot drift. Leaving
both on is harmless; leaving both off means the first query fails on a missing table.
dispatcher — how a webhook gets processed
Webhook processing is exactly the kind of work that must not be lost when a deploy or a crash interrupts it. Three backends:
| Value | Behaviour |
|---|---|
'auto' (default) | @adonis-agora/durable when its provider is registered; otherwise in-process |
'durable' | a durable workflow run — throws when durable is missing |
'in-process' | inline, retrying in the background with exponential backoff |
'auto' is the friendly default and it is also silent: an app that expected durable and forgot to
register its provider degrades to in-process without saying so. In production, name the backend —
then a missing dependency is a boot error rather than a surprise. See
Production.
The in-process fallback retries 5 times with exponential backoff (500 ms base, capped at 30 s), which covers a transient failure but does not survive a process restart. Durable does.
`durable` is the legacy alias
durable: 'auto' | true | false still works and maps onto dispatcher — true is 'durable',
false is 'in-process'. New config should use dispatcher, which names the backend directly
instead of encoding it as a boolean.
handlers — where your business logic runs
The route validates the signature, syncs the billing tables, then runs your logic. Two
equivalent authoring forms (plus a third — the app/payment_handlers/ folder — covered
in Webhooks):
-
A DI service class — the lib resolves it from the container and calls its
handle(event)method. Inject whatever you need; no boilerplate:config/payments.ts import PaymentSucceededHandler from '#payment_handlers/payment_succeeded' handlers: { 'payment.succeeded': PaymentSucceededHandler, } -
A plain function — for logic that doesn't need DI:
config/payments.ts handlers: { 'payment.refunded': (event) => { void notifyRefund(event.data) }, }
A throwing handler marks the event failed in the ledger and the dispatcher retries — so
handler failures surface instead of being silently swallowed.
Keys are validated at boot. A type in the payment.*/subscription.* namespace that is not
one of the ten WEBHOOK_EVENT_TYPES throws, and so do two handlers claiming the same type. On a
role: 'api' process, where handlers are not resolved at all, the config keys are still checked —
a misspelling is a config error on both halves of a split deployment.
passthroughEvents — the exception to that check
A gateway event a driver could not map arrives lowercased as the gateway spells it: Asaas'
PAYMENT_ANTICIPATED becomes payment_anticipated, which has no dot and needs no declaration.
This option is for the rare gateway that spells one with a dot inside the library's own
namespace, where the boot check would otherwise read it as a typo:
billing: {
passthroughEvents: ['payment.anticipated'],
handlers: { 'payment.anticipated': onAnticipated },
}The handler's event.data stays unknown for a passthrough type: nothing normalized it, and
typing it would be a claim no driver is making.
The money-flow pattern
Webhooks must respond fast. The recommended shape is: the handler only dispatches a durable workflow (durable owns retry + exactly-once) and returns — never doing the heavy grant inline. See Webhooks → The money-flow pattern.
Telescope — there is no key for it
The typed payments Telescope watcher has no configuration. There is no telescope key in
config/payments.ts; adding one does nothing, and the config type does not accept it.
The provider registers the watcher itself, in ready(), and disposes it on shutdown. What an
option would have said, the code detects instead:
- Telescope is not installed. The import fails, the provider registers nothing, and nothing is logged about it. An app without observability is a normal app, not a misconfigured one, and a missing timeline is not a reason to refuse to take payments.
- You wire your own watcher. A watcher claims the payments diagnostic channels when it
registers.
start/files run beforeready(), so an app that registered its own has already claimed those channels by the time the provider looks, and the provider stands down. Your registration is the one that runs, and nothing is recorded twice.
Delete any hand-written `start/telescope.ts` wiring
The provider registers PaymentsWatcher itself, so the six lines some apps copy into
start/telescope.ts are dead weight. The hand-wired version still works — the claim check makes
sure it is not doubled — but delete it.
The generic DiagnosticsWatcher telescope ships records the payments channels either way: they are
plain node:diagnostics_channel publishes. See Diagnostics.
Idempotency
Why gateways redeliver, what the ledger guarantees and what it does not, why the event is recorded before any work runs, how a failed event is retried, and where your own inner guard belongs.
Providers
The gateways with a built-in driver — what each one accepts, how its webhooks are authenticated, what externalReference maps to, and which operations it simply does not expose.