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:healthpayments: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_DELETEDThe 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 --createThis 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| Flag | Meaning |
|---|---|
--customer | one gateway customer id |
--all | every gateway customer recorded in billing_customers (paged) |
--provider | which 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| Flag | Meaning |
|---|---|
--stuck-after | minutes an event may sit unfinished before it counts as stuck (default 15) |
--unconfirmed-after | minutes a charge may stay pending before it counts as unconfirmed (default 120) |
--window | hours that failures are counted over (default 24) |
--dispute-window | hours ahead to look for a closing evidence window (default 72) |
--json | print 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.000ZThe 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 userThe file is app/models/<name>_model.ts — make:billable user writes app/models/user_model.ts,
not user.ts. The class is named after the argument:
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.succeededimport { 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.
Testing
Test the billing layer without a gateway or a database — FakePaymentsDriver records every call, InMemoryBillingStore mirrors the Lucid models, MutableClock drives time — and the integration suite that runs the real schema against a real Postgres.
API reference
Every config option, driver method, exported helper, domain type, event and command — from source.