Agora

Custom providers

Write a custom payment or invoice provider as a plain config factory, using the exported building blocks — httpRequest, toDecimal, emitInvoiceIfRequested, ensureCustomer, webhook security helpers.

The library is built so you never have to wait for a new gateway or invoice provider to ship: custom drivers are plain factories in config/payments.ts, and the building blocks they need are exported from the package root. When a gateway adds a feature the bundled driver hasn't caught up with, you can even extend the bundled driver instead of rewriting it.

The PaymentsDriver contract

A driver implements one interface. The methods fall into five groups:

  • CustomerscreateCustomer, findCustomer, updateCustomer.
  • Paymentscharge, findPayment, refund.
  • Checkout & subscriptionscreateCheckout, createSubscription, cancelSubscription, updateSubscription, findSubscription, listInvoices.
  • DisputesfindDispute?, submitDisputeEvidence?. Both optional, and the only optional methods on the contract; see Disputes.
  • WebhooksparseWebhook(rawBody, headers) (validate + normalize).

Plus three read-only declarations that drive routing:

readonly provider: string                    // e.g. 'my_gateway'
readonly supportedMethods: readonly PaymentMethodName[]  // which methods routing may send here
readonly capabilities?: {
  disputes?: boolean
  refunds?: boolean
  invoices?: boolean
  subscriptions?: boolean
}
readonly webhookVerification?: 'configured' | 'unconfigured' | 'unsupported'

capabilities is what the manager checks before delegating, so an app discovers a limitation early (e.g. a Pix-only gateway with no refunds) instead of at the gateway. For the four required-method groups a driver that lacks a capability still implements the method — it just throws a clear error; the manager's assertCapability surfaces that before the call.

webhookVerification — declare it, or be treated as unable

The provider mounts POST /payments/webhook/:provider for every configured driver, and that route is reachable from the public internet. A driver whose credential slot is empty verifies nothing, so anyone who knows the URL can post a body that marks a payment paid. Declaring the state is what lets the library refuse to boot instead of serving that route silently.

get webhookVerification(): WebhookVerificationState {
  return this.#webhookSecret !== undefined ? 'configured' : 'unconfigured'
}
ValueMeaningAt boot
'configured'it can verify and it has the credentialroute mounts
'unconfigured'it can verify and nothing was configuredthrows, unless the provider is in allowUnverifiedWebhooks
'unsupported'the gateway signs nothing — there is nothing to configureroute mounts

It is optional, so a driver written against an older version keeps compiling — and absent is read as 'unsupported', because a driver that never opted in cannot be assumed to verify. Declaring 'unsupported' when your gateway does sign is the one dishonest answer available here; it turns the boot check off for your driver and nothing else will notice.

idempotencyKey is a promise, not a hint

CreateCustomerInput, ChargeInput, CheckoutInput, UpdateSubscriptionInput, CreateSubscriptionInput and refund() all carry an optional idempotencyKey. A gateway with no deduplication mechanism must make your driver refuse it — throw — rather than accept and ignore it. Accepting it silently turns a caller's retry guarantee into a second charge, a second refund or a second subscription, and the caller has no way to find out.

A custom payment gateway

app/services/payments/my_gateway_driver.ts
import {
  type PaymentsDriver,
  type ChargeInput,
  type Customer,
  type Payment,
  type WebhookEvent,
  toDecimal,
  httpRequest,
  isNotFound,
  emitInvoiceIfRequested,
  type EmitInvoiceContext,
} from '@adonis-agora/payments'

export class MyGatewayDriver implements PaymentsDriver {
  readonly provider = 'my_gateway'
  readonly supportedMethods = ['pix', 'credit_card'] as const
  // Say what is true. `disputes: false` + no findDispute/submitDisputeEvidence is a
  // complete, honest answer for a gateway that only emails you about a chargeback.
  readonly capabilities = { refunds: true, disputes: false }

  #invoiceCtx: EmitInvoiceContext
  #baseUrl = 'https://api.mygateway.com/v1'

  constructor(ctx: EmitInvoiceContext, private apiKey: string) {
    this.#invoiceCtx = ctx
  }

  async createCustomer(input: { name?: string; email?: string; taxId?: string }): Promise<Customer> {
    const data = await httpRequest<{ id: string }>('/customers', {
      baseUrl: this.#baseUrl,
      method: 'POST',
      body: { ...input },
      bearerToken: this.apiKey,
    })
    return { id: data.id }
  }

  async charge(input: ChargeInput): Promise<Payment> {
    const data = await httpRequest<{ id: string; amount: number; currency: string; status: string }>(
      '/charges',
      {
        baseUrl: this.#baseUrl,
        method: 'POST',
        body: { amount: toDecimal(input.amount), customer: input.customerId },
        bearerToken: this.apiKey,
      },
    )
    const payment: Payment = {
      id: data.id,
      gatewayId: data.id,
      provider: this.provider,
      amount: { amount: input.amount, currency: data.currency },
      status: data.status === 'paid' ? 'paid' : 'pending',
      payload: data as unknown as Record<string, unknown>,
      createdAt: new Date().toISOString(),
    }
    await emitInvoiceIfRequested(this.#invoiceCtx, input, payment, this)
    return payment
  }

  // ... findCustomer, refund, createCheckout, createSubscription,
  //     cancelSubscription, updateSubscription, findSubscription,
  //     listInvoices, parseWebhook
}

export function myGateway(config: { apiKey: string }) {
  return (ctx: EmitInvoiceContext) => Promise.resolve(new MyGatewayDriver(ctx, config.apiKey))
}

Register it exactly like a built-in:

config/payments.ts
import { defineConfig } from '@adonis-agora/payments'
import { myGateway } from '#services/payments/my_gateway_driver'

export default defineConfig({
  default: 'my_gateway',
  providers: {
    my_gateway: myGateway({ apiKey: env.get('MY_GATEWAY_KEY') }),
  },
  methods: {
    pix: 'my_gateway',
    credit_card: 'my_gateway',
  },
})

The exported building blocks make the common work trivial:

  • httpRequest — the thin fetch wrapper with auth headers and normalized errors (a status property, so isNotFound works uniformly).
  • toDecimal / fromDecimal — integer cents ↔ gateway decimal conversion (never float-multiply money).
  • emitInvoiceIfRequested — honors the invoice option on a charge, so your driver gets invoice emission for free.
  • ensureCustomer — the reuse-or-create branch for customer ids.
  • Webhook security helperssafeCompare, verifyHmacSignature, verifyRsaSha256Signature, requireMatchingCredential for signature validation in parseWebhook.

Webhooks — one event, or several

parseWebhook returns WebhookEvent | WebhookEvent[] | Promise<WebhookEvent | WebhookEvent[]>.

One event is still the normal answer. The array half exists for the two gateways whose envelope carries a list — Adyen's notificationItems, Efí's pix — and the union is a widening, not a migration. If your gateway sends one event per request, return one: an array of length one adds nothing and reads as if batching were possible.

If it can send several, three rules are not optional:

  • Every event needs an id of its own. The idempotency ledger keys on WebhookEvent.id, so reusing one id across a batch makes the second event look like a redelivery of the first and it is silently skipped, with the money already moved.

    The same rule bites outside a batch, and it caught this package: an id built from the event name and the payment id is a (payment, event-type) identity, not an event identity, so the second update about one payment is discarded as a replay of the first. Use the gateway's own event id where there is one. Where there is not, hash the raw body — deterministic, so a genuine redelivery still deduplicates, while two different notifications differ. Never Math.random(): that turns deduplication off and every retry of a failing delivery looks new.

  • You must have authenticated all of them. Where the gateway signs per event, verifying the first and trusting the rest is a forgery hole — an attacker replays one genuine item and appends whatever they like. Verify every item in one pass, map in a second, and keep the two separate so no later edit can reorder its way into building events from unverified items. One bad signature rejects the whole delivery: a partly-forged body is a forged request.

  • Throwing rejects the whole delivery. A throw out of parseWebhook answers 400 and none of the events in that body are processed, however many of them were fine. Throw for a bad signature or an unparsable body — a partly-forged batch is a forged request. Never throw for an event you merely did not recognize: map it to payment.updated, or return it unmapped and let the processor pass it through.

    A rejected delivery leaves no ledger row — it is refused before an event exists — so the route files a webhook.rejected audit row instead. That row and the rejected_deliveries health check are the only places a refused delivery is visible.

An unmapped event should be passed through lowercased as the gateway spells it (PAYMENT_ANTICIPATEDpayment_anticipated). Do not invent a payment.something name for it: that namespace is the library's own, and the boot-time handler check treats an unknown type inside it as a typo. payment.updated is the right target only when the event really does mean "this payment changed" — and note that it now carries a built-in sync, so its data should include the payment's current status, paidAt and refundedAmount where the gateway states them.

The return may be a promise, and one driver needs that: Mollie's webhook is a bare payment id with no status and no signature, so the only way to learn what happened — or that the call is genuine at all — is an authenticated fetch of that payment. The mounted route awaits parseWebhook.

Disputes — the two optional methods

findDispute and submitDisputeEvidence are the only optional members of the contract, and they are optional for a real reason: a gateway can settle money perfectly and give you nothing but an email when a chargeback is filed. Declare what is true:

readonly capabilities = { disputes: false }
// ...and simply omit findDispute / submitDisputeEvidence.

Do not stub them to return an invented Dispute. The whole point of the dispute vocabulary is that a deadline nobody guessed is the only deadline worth alerting on, and a fabricated evidenceDueBy is worse than none. Of the eighteen bundled gateways, several genuinely have no dispute API, and their pages say so.

A driver with no dispute API can still report disputes over the webhook, and should. What capabilities.disputes = false means is "you cannot read or answer one over the API", not "chargebacks do not happen here". Map what the gateway sends onto the three canonical events, on one question — has the gateway taken the money yet:

EventMeaningThe row
payment.dispute_warninga pre-chargeback alert; no money has movedstays paid
payment.disputedthe funds have been withdrawnmoves to disputed
payment.dispute_closedresolved; carries outcomewon restores paid; the rest stay

payment.dispute_closed must carry an outcome (won/lost/canceled/expired) — the processor throws on a close without one rather than defaulting, because defaulting would report a result the gateway never sent. If your gateway's close event does not say which way it went, emit payment.updated instead.

The dispute event's data is DisputeWebhookData: gatewayId is the payment's id, with the dispute's own id in disputeId, plus the optional reason, actionableUntil (the deadline), outcome, amount and currency. It is deliberately looser than a payment event's data — a Stripe early fraud warning names a charge and a fraud type and no money at all, and refusing it for that would throw away the earliest warning available.

If the gateway does expose a dispute API, implement both:

readonly capabilities = { disputes: true, refunds: true }

async findDispute(disputeGatewayId: string): Promise<Dispute | null> { /* ... */ }

async submitDisputeEvidence(
  disputeGatewayId: string,
  evidence: DisputeEvidence,
): Promise<Dispute> { /* ... */ }

Two constraints on the second one. Most gateways accept evidence once, so a driver must never retry it on its own. And DisputeEvidence.documents addresses each file by what it proves (receipt, shipping, terms, …) rather than as a bare list of ids, because that is how every gateway files them — Stripe has nine separate evidence fields, Adyen a defenseDocumentTypeCode, and neither can be reached with "here are some files". The documents are gateway file ids, never URLs: the banks reviewing a dispute do not follow links.

A driver that cannot read a dispute back should refuse

submitDisputeEvidence returns a Dispute. If the gateway's API has no endpoint that reads one back — Adyen's v30 is the case in this package — the returned object would be invented, so the method throws instead. Refusing is the honest answer; the alternative is an app that believes it filed a defense it cannot see.

Reusing a gateway customer

Every charge/subscription flow needs a gateway customer id; ensureCustomer does the "reuse the stored id, or create at the gateway" branch for you (the app owns the user→customer mapping and persists the new id):

app/services/subscribe_service.ts
import { ensureCustomer } from '@adonis-agora/payments'

const customer = await ensureCustomer(driver, user.billingCustomerId, {
  name: user.fullName,
  email: user.email,
  taxId: user.cpfCnpj ?? undefined,
})
user.billingCustomerId = customer.id
await user.save()

A custom invoice provider

Check the built-ins first — Focus, eNotas, PlugNotas, Tecnospeed and Asaas all ship. What follows is for a municipal API none of them covers.

The InvoiceProvider contract is even smaller — emit(input) and find(id):

app/services/invoices/prefeitura_driver.ts
import { type InvoiceProvider, type InvoiceEmitInput, type Invoice, httpRequest } from '@adonis-agora/payments'

export class PrefeituraDriver implements InvoiceProvider {
  readonly provider = 'prefeitura'

  async emit(input: InvoiceEmitInput): Promise<Invoice> {
    const data = await httpRequest<{ numero: string; link: string }>('/nfse', {
      baseUrl: 'https://nfse.minhacidade.gov.br/v1',
      method: 'POST',
      body: {
        tomador: { cpf_cnpj: input.customer.taxId, nome: input.customer.name },
        valor: input.amount / 100,
        descricao: input.service.description,
      },
      bearerToken: process.env.PREFEITURA_TOKEN!,
    })
    return {
      id: data.numero,
      gatewayId: data.numero,
      provider: this.provider,
      number: data.numero,
      hostedPdfUrl: data.link, // the field is `hostedPdfUrl`, not `url`
      status: 'issued',
      amount: { amount: input.amount, currency: input.currency },
      createdAt: new Date().toISOString(),
      payload: {},
    }
  }

  async find() {
    return null
  }
}
config/payments.ts
invoice: {
  default: 'prefeitura',
  providers: {
    prefeitura: () => import('#services/invoices/prefeitura_driver').then((m) => new m.PrefeituraDriver()),
  },
}

Extend an existing driver

When a gateway ships a feature the library hasn't caught up with yet (e.g. AbacatePay starts supporting credit cards), extend the bundled driver instead of rewriting it:

app/services/payments/abacate_with_card.ts
import { AbacateDriver } from '@adonis-agora/payments/drivers/abacate'
import type { ChargeInput, Payment } from '@adonis-agora/payments'

export class AbacateWithCardDriver extends AbacateDriver {
  override readonly supportedMethods = ['pix', 'boleto', 'credit_card', 'undefined'] as const

  override async charge(input: ChargeInput): Promise<Payment> {
    if (input.method === 'credit_card' || input.card) {
      // Implementa o fluxo de cartão que a lib ainda não cobre nativamente.
      return this.chargeWithCard(input)
    }
    return super.charge(input)
  }
}
config/payments.ts
providers: {
  abacate: () => import('#services/payments/abacate_with_card').then((m) => new m.AbacateWithCardDriver()),
},
methods: {
  credit_card: 'abacate',
}

The router's supportedMethods validation accepts credit_card for this provider, and the rest of the library (routing, invoices, webhooks) works unchanged.

Drivers are just objects

Because a driver is a plain object (interface + declared metadata), testing a custom driver is trivial: instantiate it, call the methods, assert the normalized shapes. No mock framework needed for the contract itself — the testing kit covers the billing layer on top of it.

On this page