Agora

CLI

The five ace commands — scaffolding a billable model and a webhook handler, printing (or creating) the webhook endpoints, reporting the health of the billing install, and reconciling the local tables with a gateway.

node ace list | grep -E 'payments|billable|webhook-handler'
# make:billable
# make:webhook-handler
# payments:webhook
# payments:sync
# payments:health

payments:webhook

Prints the endpoint URL and the event list for every configured provider — the two things each gateway dashboard asks for.

node ace payments:webhook
node ace payments:webhook --provider=asaas
[asaas] (asaas)
  URL: https://your.app/payments/webhook/asaas
  Events: PAYMENT_CREATED, PAYMENT_RECEIVED, PAYMENT_OVERDUE, PAYMENT_REFUNDED,
          SUBSCRIPTION_CREATED, SUBSCRIPTION_UPDATED, SUBSCRIPTION_DELETED

The event list is filtered by the resolved driver's capabilities, so a Woovi provider never advertises refund events and a gateway without subscriptions never advertises subscription events. It resolves the real driver rather than reading a table by config key, so a custom provider extending a built-in reports the right list too.

Stripe is the one gateway whose endpoints can be created over its API:

node ace payments:webhook --create

This needs STRIPE_KEY in the environment, creates the endpoint with the full event list, and echoes the signing secret for you to put in .env:

Created Stripe webhook endpoint we_1abc…
Webhook signing secret (add to .env):
  STRIPE_WEBHOOK_SECRET=whsec_…

The URL comes from your app's configured base URL

It is built with the app's URL helper, so a wrong host in the output means APP_URL (or your equivalent) is wrong — worth fixing before pasting the endpoint into a dashboard.

payments:sync

Reconciles the local billing tables with a gateway. This is the repair tool for the two situations webhooks cannot cover: an outage during which events were dropped, and a change made by hand in the gateway dashboard.

node ace payments:sync --customer=cus_123
node ace payments:sync --all
node ace payments:sync --all --provider=stripe
FlagMeaning
--customerone gateway customer id
--allevery gateway customer recorded in billing_customers (paged)
--providerwhich configured provider to sync (default: the configured default)

It pages every invoice the gateway holds for the customer, asks the gateway what each charge's payment actually says, and writes that back — in both directions. A local row saying paid while the gateway says refunded is corrected to refunded — the drift a gateway-is-truth reconcile exists for.

  cus_123: 7 invoice(s), 2 reconciled, 4 already current, 1 undecidable (gateway had no payment)
Reconciled 2 payment(s) across 1 customer(s).
  • reconciled — the local row now says what the gateway says.
  • already current — the two already agreed, so nothing was written. A reconcile over a stable install writes nothing at all rather than churning every row's updated_at.
  • undecidable — the gateway lists the invoice but has no payment resource behind it, so there is no authoritative status to copy. It is left alone, not guessed at.

The status comes from the gateway's payment resource, not from the invoice listing: an Invoice['status'] has no refunded and no disputed member, so a reversed charge appears in the listing as draft. The listing enumerates; findPayment decides.

Three guards fail fast rather than doing nothing quietly: neither --customer nor --all is an error rather than a silent no-op, a gateway with no invoice concept (Woovi/OpenPix) is refused up front by the capability check instead of listing nothing, and a paging loop that never terminates throws instead of returning a partial list as if it were whole.

A local `disputed` row is never reconciled away

A chargeback is money the bank has already pulled back, and the gateway's payment resource frequently goes on reporting the charge as received while the dispute is open. Reconciling that to paid would re-count money that is gone and silence the dispute at the same time — so a local row in disputed is left alone, named on the console, and counted in the tail of the summary:

  pay_1abc: local row is disputed, gateway says paid — left alone.
Reconciled 2 payment(s) across 1 customer(s); 1 disputed row(s) left alone.

Only payment.dispute_closed, which carries an outcome, resolves a dispute — or the console's resolve action, for a gateway that never sends one.

Settlement dates come from the gateway, or not at all

revenue() windows on paid_at, so the reconcile takes the gateway's own settlement date, never overwrites one already recorded, and records none when the gateway states none. Stamping new Date() on every row it wrote would file historic money in the month the reconcile ran — run it in two different months and the same charge lands in both.

`--all` iterates what you recorded

It pages billing_customers through the store, and that table is opt-in: it is written by ensureCustomer({ store, owner }) (or store.saveCustomer() directly). An app that never records mappings has nothing for --all to reconcile over — use --customer with the ids you hold until it does. --provider narrows the iteration to one gateway's customers.

Sync writes to billing tables, not to your business logic

It brings billing_payments back in line with the gateway. It does not run your webhook handlers, so credits are not granted and subscriptions are not activated by it. After a large reconcile, check what your own tables are missing — or replay the events from the gateway dashboard so the handlers run through the normal path.

payments:health

The scheduled check for the six failures a billing install has that nothing throws on. Three of them are things that broke. The other three are money: a chargeback whose evidence window is about to close, a chargeback nobody has answered at all, and a delivery the endpoint refused.

node ace payments:health
node ace payments:health --window=48 --json
node ace payments:health --dispute-window=168   # a week of chargeback windows
FlagMeaning
--stuck-afterminutes an event may sit unfinished before it counts as stuck (default 15)
--unconfirmed-afterminutes a charge may stay pending before it counts as unconfirmed (default 120)
--windowhours that failures are counted over (default 24)
--dispute-windowhours ahead to look for a closing evidence window (default 72)
--jsonprint the report as JSON
0  Events claimed but unfinished for over 15m
2  Events the dispatcher gave up on in the last 24h
     Handlers threw and retries ran out; those events never took effect.
1  Charges created over 2h ago and still pending
     Charges are being created but never confirmed — the shape of a webhook endpoint that
     stopped being reachable.
1  Open disputes whose evidence window closes within 3d
     A chargeback window is closing. Past it the dispute is lost by default rather than on
     the merits, and nothing can be done.
3  Disputes still open and unanswered
     A chargeback is open and the money is already out of the account. This check does NOT
     need a deadline, which is the point.
0  Gateway deliveries refused in the last 24h
  stripe payment.succeeded: 2 failed
  stripe dp_1Nx4 (payment pi_3Qx7): evidence due 2026-08-30T09:00:00.000Z

The dispute check names each closing window — the dispute id, the payment it is against and the deadline — because a count sends nobody anywhere. The list stops at twenty; the count does not, so a report can name twenty of fifty without pretending it named all of them. A window that has already closed keeps being counted: it is still open and still unanswered, and going quiet the moment it expires would read as "resolved" at exactly the wrong moment. A dispute the gateway sent no deadline for is never counted there; there is nothing to be late for. That is what open_disputes is for.

open_disputes has no threshold and no flag

disputes_due can only ever see a dispute whose gateway published a deadline, and most do not: on Asaas it comes from chargeback.deadlineToSendDisputeDocuments, which no published webhook example even contains. On such an install the deadline check answers zero forever while a chargeback sits open with the money already pulled back — a healthy report about an install that is losing money by default.

open_disputes counts every dispute in warning, open or under_review, with no window and no way to silence it. An open chargeback is money already out of the account, so there is no horizon at which it stops mattering. billingHealth() also returns openDisputes — up to twenty rows, oldest first, because with no deadline to sort on, age is the only priority signal left.

rejected_deliveries is the only trace a refused delivery leaves

A delivery the endpoint answers 400 to — a bad signature, an unparsable body, a provider nobody configured — never becomes a ledger row, because it is refused before an event exists to record. So without a record of its own, a rotated webhook secret looks exactly like a quiet week: zero events, zero failures, every check green. Each rejection writes a webhook.rejected row into billing_audit_events, and this check counts them over the last 24 h. Read them with listAuditEvents({ action: 'webhook.rejected' }), or on the console's Activity screen.

Two checks have no flag of their own

--stuck-after, --unconfirmed-after, --window and --dispute-window are the whole flag set. open_disputes has no threshold by design. rejected_deliveries uses a fixed 24 h window from the command; billingHealth(store, { rejectedWithin }) takes one if you call the function directly.

It exits non-zero when any count is non-zero, so a cron entry or a container healthcheck pages on it without parsing the output. It also exits non-zero, with nothing checked, when billing.enabled is false — there is no install to report on. The same report is a function — billingHealth(store) — for a scheduled job that ships it to your metrics. See Health checks.

make:billable

Scaffolds a Lucid model composed with the billing mixins:

node ace make:billable user

The file is app/models/<name>_model.tsmake:billable user writes app/models/user_model.ts, not user.ts. The class is named after the argument:

app/models/user_model.ts
import { BaseModel, compose } from '@adonisjs/lucid/orm'
import { column } from '@adonisjs/lucid/orm'
import { withBillable } from '@adonis-agora/payments'

export default class User extends compose(BaseModel, withBillable()) {
  @column({ isPrimary: true }) declare id: string
}

See Billing for what the mixins add and how to stack the narrower ones.

make:webhook-handler

Scaffolds a handler under app/payment_handlers/ — the convention folder the provider auto-discovers:

node ace make:webhook-handler payment.succeeded
app/payment_handlers/payment_succeeded.ts
import { inject } from '@adonisjs/core'
import type { WebhookEventFor } from '@adonis-agora/payments'

@inject()
export default class PaymentSucceededHandler {
  static readonly eventType = 'payment.succeeded'

  async handle(event: WebhookEventFor<'payment.succeeded'>): Promise<void> {
    const { externalReference } = event.data   // typed — no cast
    // dispatch durable work — never grant inline
  }
}

WebhookEventFor<T> types event.data from the event type, so a handler needs no event.data as PaymentWebhookData cast to open it — and reading a payment field off a dispute event is a compile error rather than undefined at runtime.

The file is discovered at boot with no further wiring. node ace add registers an Assembler init hook that generates a barrel of the folder at build and dev time (the file watcher re-runs it); when that hook is absent the provider falls back to a runtime scan, so the convention works either way. See Reacting to payments.

A misspelled `eventType` no longer boots

eventType is validated at boot. A type in the library's own payment.*/subscription.* namespace that is not one of the normalized WEBHOOK_EVENT_TYPES throws, naming the file — because 'payment.suceeded' would otherwise register a handler nothing ever calls, while the delivery is still recorded as processed and the route still answers 200. Two handler files claiming the same type throw too, naming both. See Webhooks.

On this page