Agora

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:

  1. Which gateways do I accept payments through?providers (a named map of driver factories).
  2. Which gateway handles each payment method?methods (e.g. pix → woovi, credit_card → stripe).
  3. Who emits my invoices?invoice (independent of the gateway).
  4. 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:

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

config/payments.ts
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') }),
},
  • default names 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:

DriverKey configConvenience env fallback
StripeapiKey, currency (required), webhookSecretSTRIPE_KEY, STRIPE_WEBHOOK_SECRET
AbacatePayapiKey, webhookSecret/publicKeyABACATE_API_KEY, ABACATE_PUBLIC_KEY
AsaasapiKey, sandbox, webhookTokenASAAS_API_KEY, ASAAS_WEBHOOK_ACCESS_TOKEN
WooviappId, webhookSecret, webhookPublicKeyWOOVI_APP_ID, WOOVI_WEBHOOK_SECRET, WOOVI_WEBHOOK_PUBLIC_KEY
PagBanktoken, sandbox, webhookTokenPAGBANK_TOKEN, PAGBANK_WEBHOOK_TOKEN
EfíclientId, clientSecret, pixKey, certificateEFI_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.

config/payments.ts
export default defineConfig({
  providers: { efi: payments.efi({ /* ... */ }) },
  allowUnverifiedWebhooks: ['efi'],
})
ValueMeaning
omitted (default)any driver reporting 'unconfigured' throws at boot, naming the provider
['efi', 'asaas']those providers may take unverified deliveries
trueevery 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:

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

  1. Unknown method/provider → the manager throws with a helpful list of what's configured, instead of a confusing SDK error.
  2. Method not supported by the routed gateway → early throw. Routing credit_card to 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 checks driver.supportedMethods before returning the driver.
  3. driver() with no argument consults methods too. Exactly one method routed to that provider is applied, the same binding driver('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.
  4. Ambiguity is refused, not guessed. When methods routes several methods to the provider driver() resolved, there is no honest answer — so charge() and createSubscription() throw unless the call names a method:, and the message names both ways to say what you meant. Every other method on the driver is untouched. An app that already passes method: 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.

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

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

OptionDefaultWhat it does
enabledtruefalse wires no store, processor or dispatcher. The webhook route still mounts and verifies, and then does nothing with what it verified.
autoCreateSchematrueThe 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.
storethe Lucid store over the billing tablesWhere the layer persists. billingStores.lucid({ models }) swaps the models; a factory of your own replaces the store entirely. See Billing → The billing store.
handlersnoneYour business logic, keyed by normalized event type. See below.
passthroughEventsnoneGateway 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.
durableLegacy 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.

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

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

ValueBehaviour
'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 dispatchertrue 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:

config/payments.ts
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 before ready(), 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.

On this page