Agora
Patterns

Pix

A one-off Pix charge end to end — the shared shape, then exactly what Asaas, Woovi and AbacatePay each require, what each returns, and the errors each one throws when something is missing.

Pix is the flow most Brazilian apps start with, and the gateways that serve it disagree on almost every detail: whether a customer is required, where your reference goes, what the QR looks like coming back. The library normalizes the result; it cannot normalize away what each gateway demands on the way in.

Ten drivers accept pix. This page works through the three you are most likely to start with — Asaas, Woovi and AbacatePay — and the other seven (Stripe, Efí, Pagar.me, PagBank, Mercado Pago, Dodo and InfinitePay) each have their own page under Providers. Two of them are called out further down, because they behave in ways this page's three do not.

The shape that is the same everywhere

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

export async function startPixCheckout(user: User, plan: Plan) {
  const driver = getPayments().driver('pix')

  const order = await Order.create({
    userId: user.id,
    planId: plan.id,
    total: plan.priceCents,
    status: 'awaiting_payment',
  })

  const payment = await driver.charge({
    customerId: user.billingCustomerId!,
    amount: order.total,               // integer cents
    description: plan.name,
    externalReference: order.id,       // ← how the webhook finds this order
    idempotencyKey: `order:${order.id}`,
  })

  return {
    order,
    qrImage: payment.pixQrCodeImage,  // base64 PNG, when the gateway returns one
    copyPaste: payment.pixCode,       // the BR code to show as text
  }
}

payment.status is 'pending'. It stays pending until the gateway calls your webhook — see Reacting to payments.

What each gateway needs

Showing Asaas — pick your gateway in the sidebar to see yours.

Requires a customer. A charge without customerId throws before any request goes out:

[payments] Asaas requires a customer for every charge.
app/services/checkout_service.ts
const driver = getPayments().driver('pix')   // routed to asaas

// Reuse the stored gateway customer, or create one.
const customer = await ensureCustomer(driver, user.billingCustomerId, {
  name: user.fullName,
  email: user.email,
  taxId: user.cpfCnpj,
})
if (customer.id !== user.billingCustomerId) {
  user.billingCustomerId = customer.id
  await user.save()
}

const payment = await driver.charge({
  customerId: customer.id,
  amount: 1990,
  description: 'Plano Pro',
  externalReference: order.id,
  // Asaas charges have a due date. Default: 7 days out.
  metadata: { dueDate: '2026-09-15' },
})

Your reference goes to Asaas' own externalReference field, and — this is the one worth remembering — it is propagated to every installment of a subscription's charges. See Subscriptions.

The QR takes a second call. Asaas does not return the Pix payload when the charge is created, so the driver fetches /payments/:id/pixQrCode right after and fills both fields for you:

payment.pixCode         // the BR code the customer copies
payment.pixQrCodeImage  // base64 PNG of the QR

If that follow-up fails the charge still succeeds — you get a pending payment with no QR, which is worth handling in the UI rather than assuming.

Due date defaults to 7 days from now. Override it with metadata.dueDate as YYYY-MM-DD.

The differences at a glance

AsaasWooviAbacatePay
Customer requiredyesnoyes
Payer name + taxId requirednonoyes
Your reference lands inexternalReferencecorrelationIDexternalId
Propagated to subscription chargesyes
QR returned by the create callno — a second call fetches ityesyes
Due date7 days, metadata.dueDate to override
Refundsyesnoyes
Other methodsboleto, card, debitboleto

Showing the QR

The normalized fields are the same regardless of which gateway served the charge, which is the point:

app/components/pix_checkout.tsx
export function PixCheckout({ qrImage, copyPaste }: { qrImage?: string; copyPaste?: string }) {
  return (
    <>
      {qrImage && <img alt="QR code Pix" src={`data:image/png;base64,${qrImage}`} />}
      {copyPaste && (
        <button type="button" onClick={() => navigator.clipboard.writeText(copyPaste)}>
          Copiar código Pix
        </button>
      )}
    </>
  )
}

Both are optional in the type because a gateway can legitimately fail to return one. Render the copy-paste code as the fallback — it always works, and it is what most people use anyway.

Then wait for the webhook

Nothing above moved money. The order stays awaiting_payment until a payment.succeeded arrives — see Reacting to payments for where that logic lives and how to make it safe to run twice.

A Pix can still be taken back

A Pix cannot be charged back the way a card can — there is no issuing bank and no representment. It can be reversed, and the two gateways that report it do so as disputes rather than as refunds.

Efí — the Banco Central's MED. A devolução on the Pix webhook carries a natureza, and three of its values (MED_OPERACIONAL, MED_FRAUDE, MED_PIX_AUTOMATICO) mean the money is being returned to a payer who reported fraud or an operational failure — not a refund the merchant chose to give. Mapping all of them to payment.refunded would say the merchant chose to give the money back. Instead:

  • status: 'EM_PROCESSAMENTO'payment.dispute_warning (the return is executing)
  • status: 'DEVOLVIDO'payment.disputed (the money is gone)
  • status: 'NAO_REALIZADO' → no dispute event at all; the return took nothing

A non-MED devoluçãoORIGINAL, RETIRADA, or no natureza — is still an ordinary payment.refunded. Note what the warning here is not: Efí's Pix webhook has no "a MED was opened" notification, so the warning means "this is happening now", not "you have time to defend". There is no deadline field anywhere in the payload, and none is invented.

Woovi — OPENPIX:DISPUTE_CREATED. While a dispute is under analysis Woovi blocks the balance rather than taking it, so the open is a payment.dispute_warning and Woovi never emits payment.disputed at all; the sequence is warning → closed. The three-day window to send evidence is published policy, not a field, so actionableUntil is empty.

A Woovi dispute names the Pix, not the charge

The dispute payload carries no charge and no correlationID — only an endToEndId — so gatewayId on all four Woovi dispute events is the Pix end-to-end id, while charges are stored under charge.globalID. The event does not find its payment row by itself. Persist event.data.metadata.endToEndId from the paid webhook and the two join.

Both write a row into billing_disputes and both appear in the console's Disputes log. Neither appears in payments:health's closing-window check, because that check reads a deadline and neither gateway sends one — which is exactly the case the Disputes page warns about: a null deadline is "we were told nothing", not "there is time".

On this page