Agora

Diagnostics

Every payments event on the @adonis-agora/diagnostics bus — the gateway-action, business and debug layers, the structural emit slot, and debugging one payment in Telescope.

Every payments milestone emits on the @adonis-agora/diagnostics bus as agora:payments:<event>. The library reads the diagnostics slot structurally — it never imports the package — so when diagnostics isn't installed the emits are inert no-ops, and when it is, everything downstream (Telescope, OTel, custom watchers) lights up at once.

Three layers of events

The catalog spans three layers, and the distinction matters for how you react:

  • Gateway-action events fire when your code calls the gatewaycharge.created when a charge is created, charge.refunded, subscription.created/canceled, invoice.emitted, invoice.failed. They describe what the app did.

    invoice.failed is the one to subscribe to on day one if you emit fiscal invoices. A failing invoice provider no longer rejects charge() — that reported a failure over money the gateway had already taken, and the obvious response to a failed charge is to charge again. The charge succeeds, the invoice does not, and this event is the only thing that says so. In Brazil an NFS-e is a legal obligation, so a human has to act on it.

  • Business events fire when a webhook confirms a state changepayment.succeeded, payment.failed, payment.refunded, payment.disputed, payment.dispute_warning, payment.dispute_closed, payment.updated, subscription.updated. They describe what the gateway says happened. These are the ones webhook handlers and notification flows react to.

  • Debug events exist for the developer holding one broken payment, not for a dashboard — gateway.request/gateway.request.failed (what was actually sent to the gateway, what came back, how long it took) and webhook.verification (whether the signature verified, and under which scheme). Nobody subscribes to these in business code; you open them in Telescope when something has already gone wrong.

EventLayerWhenPayload highlights
charge.createdgateway-actiona charge succeeded at the gatewaygatewayId, provider, amount, currency, method
charge.refundedgateway-actiona refund completedgatewayId, provider, amount, currency
subscription.createdbothcreated via API or confirmed by webhookgatewayId, provider, customerId, planId
subscription.updatedbusinessa subscription changed (webhook)gatewayId, provider, customerId, status
subscription.canceledbothcanceled via API or confirmed by webhookgatewayId, provider
payment.succeededbusinessa webhook confirmed a paymentgatewayId, provider, amount, currency, externalReference
payment.failedbusinessa webhook confirmed a failed paymentgatewayId, provider, amount, currency, reason, externalReference
payment.refundedbusinessa webhook confirmed a refundgatewayId, provider, amount, currency
payment.disputedbusinessa chargeback was filed — the money is going backgatewayId, provider, amount, currency
payment.dispute_warningbusinessa pre-dispute alert; no money has moved yetgatewayId, provider, reason, actionableUntil
payment.dispute_closedbusinessa dispute reached its outcomegatewayId, provider, disputeId, outcome, amount, currency
payment.updatedbusinessa webhook reported a payment updategatewayId, provider, status
invoice.emittedgateway-actionan invoice was emittedgatewayId, provider, number, url
invoice.failedgateway-actionthe charge went through and the invoice did notgatewayId, provider, error
webhook.receivedlifecyclea gateway webhook arrivedid, provider, type
webhook.verificationdebuga delivery was authenticated (or was not)provider, outcome, scheme, reason, durationMs
webhook.processedlifecyclea webhook finished processingid, provider, type
webhook.failedlifecyclea webhook handler threwid, provider, type, error
gateway.requestdebugan outbound gateway call returned 2xxprovider, method, host, path, query, status, durationMs
gateway.request.faileddebugit timed out, failed to connect, or returned non-2xxthe same, plus outcome, error, status

The runtime catalog is exported as PAYMENTS_DIAGNOSTIC_EVENTS — a single source of truth for watchers and tools that iterate the full list.

Reacting to the business events

The payment.*/subscription.* events are the normalized business surface. Subscribe with onDiagnostic (framework-agnostic — HTTP, workers, ace commands all work):

start/payments.ts (preloaded)
import { onDiagnostic } from '@adonis-agora/diagnostics'

onDiagnostic('payments', 'payment.succeeded', ({ payload }) => {
  const payment = await Payment.find(payload.externalReference)
  // grant access...
})

Events are fire-and-forget

A throwing subscriber does not fail the webhook — the event was already ledgered. For work that must not be lost, use the webhook handlers (which mark failed + retry) or dispatch a durable workflow. See Webhooks → Running your business logic.

How emission works — the structural slot

@adonis-agora/payments never imports @adonis-agora/diagnostics. Instead it reads a well-known process-wide slot:

const EMIT_SLOT = Symbol.for('@agora/diagnostics:emit')

When diagnostics is loaded, it publishes its emit(lib, event, payload) function on that slot; payments calls it structurally. If the slot is empty, the emit is a no-op. The same trick is used for claims (so a lib-specific Telescope watcher can opt channels out of the generic watcher) and for tracing. Zero dependency, zero overhead when nobody listens.

Watching in Telescope

@adonis-agora/telescope ships a generic DiagnosticsWatcher that records every agora:<lib>:<event> publish automatically — no per-library watcher needed. Install telescope and the agora:payments:* entries show up in the timeline as diagnostic entries with the payload preserved.

The typed payments watcher

You do not wire this, and there is nothing to configure. If @adonis-agora/telescope is registered in the app, the payments provider registers the typed watcher and releases it on shutdown. No import, no start/telescope.ts, no option.

Two things could have been options and are detected instead:

  • "I wire my own." A watcher claims the payments channels when it registers, so an app that already has one has claimed them before the provider looks, and the provider stands down. Nothing is recorded twice. This is why the registration happens in the provider's ready() rather than boot(): start/ files run first, so your wiring always wins the race.
  • "I do not use telescope." Then it is not in the app, and nothing happens. An app without observability is a normal app, not a misconfigured one — it should not fail to boot and it should not be told about it on every start.

Why the provider does this for you

The wiring never varies — read the store off the container, hand record to the watcher — so it belongs in the provider rather than in six lines every app copies into start/telescope.ts. Without it a payments timeline shows generic diagnostic entries with the payload buried, instead of typed payments ones.

If you have those six lines today, delete them. The claim check means keeping them is not harmful — yours registers first and the provider stands down, so nothing records twice — but they are dead weight now.

A write to the telescope store that fails is swallowed. Recording is observability, and record runs synchronously inside the charge that published the event — a store that is full, locked or briefly unreachable must not take a payment webhook down with it.

PaymentsWatcher and registerPaymentsWatcher are exported from @adonis-agora/payments/telescope if you need to drive one yourself against a second store.

The entry content is shaped, not spread blindly: event, then the correlation id, then the fields you scan a timeline by (provider, gatewayId, id, type, method, host, path, status, outcome, scheme, amount, currency, durationMs, error, reason), then whatever else the payload carried, then ts.

Debugging one payment

The events above tell you a payment succeeded. The two below tell you why one didn't.

What was actually sent to the gateway

Every call made through the shared HTTP transport publishes gateway.request, or gateway.request.failed when it timed out, could not connect, or came back non-2xx:

{
  "event": "gateway.request.failed",
  "traceId": "0f1c…",
  "provider": "asaas",
  "method": "POST",
  "host": "api.asaas.com",
  "path": "/v3/payments",
  "query": "?access_token=[redacted]",
  "status": 422,
  "outcome": "http_error",
  "durationMs": 143,
  "error": "HTTP 422"
}

A timeout and a 422 are the two outcomes worth having, and neither left a trace anywhere before this.

Credentials are never recorded

Request headers are never recorded at all — not Authorization, not X-Api-Key, not Asaas's access_token. Query parameters keep their names (that is what makes a call identifiable) but any value under a credential-shaped key is replaced with [redacted]. The recorded error is built here rather than reused from the thrown one, because that message quotes the full URL, query string included. The entry is meant to be safe to paste into a bug report.

Bodies are opt-in, and off by default

A charge body carries the cardholder's PAN and the payer's CPF/CNPJ, so bodies are not recorded unless you ask:

start/payments.ts
import { configurePaymentsDiagnostics } from '@adonis-agora/payments'

// Debugging one integration — not a standing production setting.
configurePaymentsDiagnostics({ recordHttpBodies: true })

Turning it on adds requestBody and responseBody to the gateway.request* entries, with every value under a credential, card or tax-document key replaced, long strings truncated and long arrays cut short. Redaction is a key-name heuristic, not a guarantee — turn it on to answer a question, then turn it off.

It is a module-level switch rather than a config/payments.ts key because the HTTP transport that reads it has no access to the app config, and because it is a debugging toggle rather than a deployment setting.

Which calls appear — and which do not

gateway.request* is published by the shared httpRequest transport, so it covers every driver that goes through it: Asaas, AbacatePay, PagBank, Adyen, Mollie, Paddle, Polar, Pagar.me, InfinitePay, Mercado Pago, Razorpay, Square, Dodo, and Efí (whose mutual-TLS calls pass their own certificate-bearing fetch into httpRequest, so they are recorded like any other).

Three known holes, so they are not discovered later:

  • Lemon Squeezy — every one of its API calls uses a private fetch wrapper of its own, so none of them appear.
  • PayPal — most calls are recorded, but the OAuth token exchange and the two calls PayPal answers with 204 No Content (cancel, JSON Patch) bypass the shared transport.
  • Stripe and Woovi are SDK-based and never touch this transport at all. Their SDKs carry their own instrumentation hooks.

provider is present when the driver passes it; host always identifies the gateway either way.

Whether the webhook signature actually verified

webhook.received says a delivery arrived. webhook.verification says what authenticated it:

outcomeMeaning
verifieda shared verification helper ran and the signature matched. scheme names it — hmac-sha256, standard-webhooks, rsa-sha256, sha256-token-prefix, shared-token.
failedthe driver rejected the delivery. reason carries its message — bad signature, missing header, stale timestamp, unparsable body.
unreportedthe delivery was accepted, but nothing verified it through the shared helpers.

That last row is the one to read carefully. unreported means one of three things, and the entry deliberately does not guess which:

  1. the driver verified inside its own SDK (Stripe's constructEvent does),
  2. the gateway signs nothing at all and there is nothing to verify — Efí and InfinitePay, which declare webhookVerification: 'unsupported', or
  3. no webhook credential is configured, which requires the provider to be named in allowUnverifiedWebhooks — an app that has not said that refuses to boot.

The third case is a boot error, not a silent default, so this row is a cross-check rather than the only place it shows: unreported plus a driver that is not SDK-based and not one of the two unsigned gateways means somebody wrote an opt-out.

A known gap

The outcome is derived from what the shared webhook_security helpers report, so a driver that verifies through its own SDK cannot be told apart from one that skipped the check. Closing that needs the drivers themselves to call reportWebhookVerification() (exported for exactly this), which is a separate change.

Reading one delivery as one chain

Each webhook delivery attempt gets a traceId, established by the lib-mounted webhook route. Everything the delivery reaches inside that async context carries it — webhook.receivedwebhook.verification → the business event the built-in handler published → any gateway call your handler made → webhook.processed or webhook.failed. Filter the Telescope timeline by that one id and you have the whole story for that one event, in order.

A redelivery gets its own id, which is what you want when the question is why the retry behaved differently.

Two limits worth knowing, rather than discovering:

  • Payments cannot set the envelope's traceId. The structural emit slot is (lib, event, payload), so the id rides on the payload. It should stay that way: @adonis-agora/diagnostics fills DiagnosticEvent.traceId from your app's own request-context accessor, and overwriting that would break correlation with every other Agora library. The watcher merges both, envelope first.
  • The chain stops at a process boundary. With dispatcher: 'durable' (or a split role: 'api' / 'worker' deployment) the event is handed to another process, and the trace id is not part of the dispatched job — WebhookEvent has no field for it. The webhook.received/verification half is traced; the processing half is not.

Custom watcher / subscriber

The events are plain node:diagnostics_channel channels, so anything can subscribe:

import { onDiagnostic } from '@adonis-agora/diagnostics'

onDiagnostic('payments', 'charge.created', (event) => {
  console.log('charge created', event.payload)
})

On this page