Agora

Troubleshooting

The symptoms that actually happen — webhooks returning 400 or 500, handlers that never run, double grants, routing errors, missing invoices, a schema that upgraded halfway — and how to tell the causes apart quickly.

Sorted by how often they come up.

The app will not boot after an upgrade

Four boot refusals are deliberate, each closing a failure that would otherwise be silent. All four name the fix in the message.

The "asaas" driver can verify webhook deliveries but has no credential configured — with an empty slot there is nothing to check against, so POST /payments/webhook/asaas would accept any body anyone posted, including one that marks a payment paid. Set the credential, or, only when verification genuinely happens upstream, allowUnverifiedWebhooks: ['asaas']. Efí and InfinitePay never trip this: their gateways sign nothing, so they report 'unsupported'.

Webhook handler (…) is registered for "payment.suceeded", which is not a normalized event type — a typo in an eventType or a billing.handlers key. Unchecked it registers a handler nothing ever calls, while the ledger records the delivery as processed and the route answers 200. The rule is namespace-based: payment.*/subscription.* is the library's own, so a type there must be canonical. A gateway type a driver could not map arrives lowercased without a dot (payment_anticipated) and is accepted as-is; for the rare one spelled with a dot, list it in billing.passthroughEvents.

Two webhook handlers claim "payment.succeeded" — registration is a map keyed by event type, so the second would silently replace the first. Keep one handler per type (call the other from it) or give them different types. The message names both files.

driver() resolved "asaas", and config.methods routes pix, boleto to it — thrown from charge()/createSubscription(), not from driver(). driver() with no argument now consults config.methods, and several methods routed to one provider has no honest answer. Name the method: driver('pix') or charge({ method: 'pix' }).

The webhook returns 400

Signature validation failed. In order of likelihood:

  1. The credential is missing or wrong. Check that the value in config/payments.ts matches the one in the gateway dashboard — they are different strings per environment, and a staging secret in production fails every request.
  2. The raw body was modified. Every scheme signs the bytes, so any middleware that reserializes JSON before the driver sees it invalidates the signature. A body parser that rewrites, a proxy that reformats, a logging middleware that re-emits — all break it.
  3. Woovi is checking the wrong scheme. When webhookPublicKey is set it is enforced and webhookSecret is ignored. Setting only the deprecated HMAC while the gateway sends x-webhook-signature fails every time.

Confirm which by pointing the gateway at a staging endpoint whose provider is listed in allowUnverifiedWebhooks. If it passes there, it is the credential or the body — not the payload.

Every 400 writes a webhook.rejected row into billing_audit_events, which is the only trace it leaves: a rejected delivery is refused before an event exists, so it never becomes a ledger row. store.listAuditEvents({ action: 'webhook.rejected' }) shows the refusal reason and the provider, and the rejected_deliveries health check counts them over 24 h. Before that existed, a rotated webhook token looked exactly like a quiet week — zero events, zero failures, every check green.

The webhook route started returning 500

This is deliberate: a delivery whose handler threw answers 500, not 200.

A 2xx tells the gateway it never has to send that delivery again. Answering it over an event that failed is how a payment is lost for good — the effect never happened, the gateway never retries, and the in-process retry that was supposed to cover it dies with the next deploy. Adyen queues a failed delivery for up to 30 days of retries and Efí makes nine attempts; neither starts on a 2xx. So the 500 was always the truth, and the gateway's delivery log was already showing it as a failure at the gateway's end.

The body says what actually landed:

{ "received": true, "processed": 3, "failed": ["evt_4"], "error": "Order 1042 not found" }

error is the first failed handler's message and is the thing to read first. Then treat it exactly like a failed ledger row — same cause, same fix. The redelivery is cheap: the three that succeeded are already processed in the ledger, so the claim skips them and only evt_4 re-runs.

A 400 is a different animal and did not change: the delivery was rejected before any handler ran — a bad signature, an unparsable body, an unknown provider — and redelivering it would fail identically.

Your 5xx alert will fire on these

If you page on 5xx from /payments/webhook/*, expect it: a delivery the endpoint cannot process is reported to the gateway as a failure, so it shows on your side rather than only in the gateway's dashboard.

The webhook returns 200 but nothing happened

Work through this order:

  1. Was it a redelivery? process() returns false for an event whose id is already received or processed. await store.findWebhookEventByGatewayEventId(id) shows which — this is the ledger doing its job, not a bug.

  2. Is there a handler for that type? The processor syncs what it understands and runs a handler only if one is registered for that normalized type. A handler keyed on PAYMENT_RECEIVED never fires; the normalized name is payment.succeeded.

  3. Does the file export what discovery looks for? A handler is found only when its default export is a class with static eventType and a handle method, an object { type, handle }, or a defineWebhookHandler(...) value (which is both). A named export or a missing eventType is still silent — the file is imported and skipped. A typo in the event name no longer is: a type in the payment.*/subscription.* namespace that is not canonical throws at boot, naming the file.

    Discovery itself needs no wiring: the provider reads the generated barrel when the Assembler hook is registered (node ace add does that), and falls back to scanning app/payment_handlers/ when it is not.

  4. Did the handler throw? await store.findWebhookEventByGatewayEventId(id)failed with an error means it ran and threw, and the dispatcher is retrying. node ace payments:health counts them across the whole install.

A customer paid but never got access

The chain has four links. Check them in order, because each rules out the ones after it. Take the event id and the payment id from the gateway's dashboard and ask the store:

a repl or a throwaway ace command
import { getBillingStore } from '@adonis-agora/payments/services/main'

const store = getBillingStore()

// 1. Did the event arrive at all?
const event = await store.findWebhookEventByGatewayEventId('<gateway event id>')

// 2. Did the billing layer sync it?
const payment = await store.findPaymentByGatewayId('<gateway payment id>')
  • event is null → the gateway never called, or the request was rejected before the processor. Check the gateway dashboard's delivery log and your access logs.
  • event.status === 'failed' → your handler threw. event.error has the message.
  • event.status === 'processed', no grant in your tables → the handler ran without doing the work. Usually a missing externalReference: the handler's if (!ref) return short-circuits silently. Look at the charge — was the reference set when it was created?

Set externalReference on every charge

It is the only thing tying a gateway confirmation back to your row. A charge created without one produces a webhook your handler cannot route, and the failure is a silent return. See The payment lifecycle.

A customer was granted twice

The ledger prevents a redelivery from double-granting. It does not make your grant idempotent — see Idempotency. Two events legitimately describing the same fact (PAYMENT_RECEIVED then PAYMENT_CONFIRMED) have different ids and both pass the ledger.

The fix is an inner guard at the database, not a check in application code:

const updated = await Order.query()
  .where('id', orderId)
  .andWhere('status', 'awaiting_payment')   // ← the guard
  .update({ status: 'paid' })

if (updated[0] === 0) return

A read-then-write (if (order.status === 'paid') return) races itself under concurrent delivery.

Driver "x" does not support payment method "y"

Routing sent a method to a gateway that does not handle it — AbacatePay and Woovi are Pix-first and have no credit card. Route it elsewhere:

config/payments.ts
methods: {
  pix: 'woovi',
  credit_card: 'stripe',   // not 'woovi'
}

This is the check firing correctly. Being told at the manager, with the supported list in the message, is the whole point — the alternative is an opaque 400 from the gateway.

"x" is neither a configured provider nor a method routed in config.methods

driver('x') was given something that is neither a key of providers nor a routed method. Usually a typo, or a method that was never routed. The error lists what is configured.

Remember driver() with no argument uses config.default, and a provider name bypasses the method capability check — driver('woovi') returns the driver without validating any method, because you did not route one.

No invoice providers configured

A charge with invoice: true needs an invoice.providers section. The payment gateway and the invoice provider are independent — configuring Asaas for payments does not configure it for invoices:

config/payments.ts
invoice: {
  default: 'focus',
  providers: { focus: invoice.focus({ token: env.get('FOCUS_NFE_TOKEN') }) },
}

The charge succeeded but no invoice was emitted

Emission is resolved right after the payment is created and is best-effort from the charge's perspective — the charge does not fail because the note did not come out. Check agora:payments:invoice.emitted on the diagnostics bus: no event means emission did not complete.

The usual cause is missing payer data. The recipient defaults to the customer's details and falls back to card.holder; a Pix charge for a customer with no taxId has neither:

await driver.charge({
  customerId,
  amount: 1990,
  customer: { name, taxId, email },   // ← the payer's fiscal data
  invoice: true,
})

Subscriptions are created but never charge

Most Brazilian gateways need a first due date. Without startDate, Asaas in particular creates the subscription and generates nothing:

await driver.createSubscription({
  customerId,
  planId: 'plan_pro',
  amount: 4990,
  startDate: '2026-09-01',   // ← required
})

For a card subscription, card must also be present — otherwise there is nothing to charge each cycle.

Amounts are off by a factor of 100

Something passed reais where cents were expected. 1990 is R$ 19,90; 19.90 is nineteen cents, rounded. toDecimal/fromDecimal are for driver implementations only — application code should never call them. See Money.

If the gateway is Woovi/OpenPix and the charge at the gateway is a hundredth of what you asked for — charge({ amount: 1990 }) creating a 20-centavo charge — that was a bug in the driver, not in your call, and it is fixed. OpenPix documents value in centavos, which is the unit this package already works in, and the driver converted anyway. Upgrade; nothing in your code changes. Charges already created at the wrong amount have to be cancelled and recreated — the gateway has them at the value it was sent.

The reason it survived review: the charge path and the webhook path agreed with each other, and the tests agreed with both. Nothing was inconsistent, only wrong.

A dispute was won but the payment still reads disputed

A chargeback moves the payment row to disputed, and on an older version nothing ever moved it back. revenue() sums rows that are paid, so a dispute you won — money already returned to the account — stayed written off permanently. payment.dispute_warning and payment.dispute_closed were declared on the bus and published by nobody: the processor's switch had no case for them and they fell through to the no-op branch.

Fixed: a payment.dispute_closed carrying outcome: 'won' puts a disputed row back to paid. lost and expired deliberately leave it alone, because that money is gone, and so does canceled — on Stripe a withdrawn dispute still has to be closed in your favour with evidence, so booking it would count revenue the acquirer has not returned. Understating is the safe direction.

Upgrading does not repair the rows you already have. Those dispute_closed events are in billing_webhook_events as processed, so replaying them from the gateway dashboard is a no-op — the ledger deduplicates on the gateway event id, which is exactly what it is for. Find them and fix them directly:

const stuck = await store.listDisputes({ status: 'won', size: 200 })
// then, for each, confirm at the gateway and put the payment row back:
//   await store.savePayment({ ...existing fields..., status: 'paid' })

Omitting paidAt there is now correct: absent means "not stated" and the stored settlement date is left alone. On an older version that same write blanked paid_at, which dropped the recovered money out of every windowed revenue figure — see Revenue dropped, and nothing was refunded.

A close that carries no outcome throws rather than defaulting to one. That is deliberate: a driver that cannot read the outcome is required to emit payment.updated instead, and defaulting here would report a result the gateway never sent. See Disputes.

Revenue dropped, and nothing was refunded

Check for rows sitting at status = 'paid' with paid_at = NULL. revenue() windows on paid_at, so those rows are missing from every monthly figure while looking perfectly healthy in a list.

The cause was savePayment writing paidAt through unconditionally. A refund, a chargeback and a dispute close all write the payment row and none of them carries a settlement date — so closing a dispute in your favour restored status = 'paid' with no date and the recovered money left the books permanently. paidAt, externalReference and refundedAmount are all leave-alone now; null still clears.

Upgrading does not repair the rows. payments:sync does, because it now takes the gateway's own settlement date and never overwrites one already recorded:

node ace payments:sync --customer=cus_123

A partial refund did not change anything

A partial refund arrives as payment.updated — one of six Asaas events that map to it — and lands in two places: billing_payments.refunded_amount, written by the payment.updated handler. Without both, the money comes back while the payment row stays paid at the full amount and no screen disagrees. If a partial refund still does nothing, check in this order:

  1. The column exists. With autoCreateSchema: false, it arrives only when a new migration calls createBillingTables again — and the store skips the column rather than failing, so nothing raises. Restart afterwards; the probe is cached per process.
  2. The event arrived. store.listWebhookEvents({ type: 'payment.updated' }).
  3. The gateway said the refund settled. Only refunds Asaas reports as DONE are counted. A PENDING refund, one awaiting approval, and a denied one are all money still in the account, so the driver sends no refundedAmount at all and the stored figure is left alone.

Remember that the row stays paid and keeps its full amount, so a partial refund is invisible in a gross figure by design. revenue() is gross; netRevenue() — and the net_revenue metric on billingOverview, shown as Revenue (net) in the console — is the one that subtracts it. If the refund is recorded on the row and the number you are reading has not moved, check which of the two you are reading.

Durable is installed but webhooks run in-process

dispatcher: 'auto' falls back silently when durable's provider is not registered in the app — installing the package is not enough. Set dispatcher: 'durable' to make the fallback a boot error instead of a surprise. See Production.

column ... does not exist, or a table the library seems to ignore

You are running with billing.autoCreateSchema: false and the schema has not caught up with the package. The library creates its own tables by default and carries later columns onto a database that already has them; turning that off moves both halves to you.

createBillingTables is idempotent and does apply the columns added after a table shipped — but a migration you already ran does not run again. So an upgrade needs a new migration calling it once more, shipped in the same deploy as the code:

import { BaseSchema } from '@adonisjs/lucid/schema'
import { createBillingTables } from '@adonis-agora/payments'

export default class extends BaseSchema {
  async up() {
    this.defer(async (db) => { await createBillingTables(db) })
  }
}

The harder half of this is that skipping it does not always fail loudly. Three things are probed before use and skipped when absent, because failing every gateway delivery over a missing column is worse than recording less:

MissingWhat you see instead of an error
billing_payments.external_referencereferences are never stored, findPaymentByExternalReference always answers null, and handlers that route on it hit their silent return
billing_payments.refunded_amountpartial refunds are recorded nowhere; the row keeps its full amount, and netRevenue() answers exactly what revenue() does — there is nothing recorded to subtract
billing_webhook_events.normalizedthe dashboard's retry cannot rebuild the event and answers 422 on any signed gateway
the billing_disputes tabledisputes are never recorded, and payments:health reports a healthy zero closing windows over a table that does not exist
the billing_audit_events tableconsole refunds, dispute resolutions and rejected deliveries are recorded nowhere; rejected_deliveries reports a healthy zero and the Activity screen is empty

Anything else — a table that was never created at all — does raise relation ... does not exist or column ... does not exist on the first query that touches it.

Restart after running the DDL

Each probe runs once per process and the answer is cached for the life of it. Running the migration against a live process does not change its mind: it keeps skipping the column it decided was absent.

autoCreateSchema: false does reach a Lucid store you built yourself with billing.store — including billingStores.lucid({ models }). The provider turns auto-creation off on it after your factory returns, so no DDL runs against the database the flag was set to protect. It only ever turns the flag off: a store you constructed with autoCreateSchema: false of your own stays that way even when the config says true, and a store that is not a LucidBillingStore is left alone entirely.

Events stuck in failed

The dispatcher exhausted its retries, or a restart dropped an in-process backoff window. The error column has the reason. Once the underlying cause is fixed, replay from the gateway dashboard: a failed event is claimable again, so the redelivery re-runs it properly.

When nothing here fits

Reduce to the smallest case: the testing kit runs the whole webhook → processor → handler path with a fake driver and an in-memory store, no gateway and no database. If it works there and not in your app, the difference is configuration or middleware — not the library.

On this page