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
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.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 QRIf 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
| Asaas | Woovi | AbacatePay | |
|---|---|---|---|
| Customer required | yes | no | yes |
| Payer name + taxId required | no | no | yes |
| Your reference lands in | externalReference | correlationID | externalId |
| Propagated to subscription charges | yes | — | — |
| QR returned by the create call | no — a second call fetches it | yes | yes |
| Due date | 7 days, metadata.dueDate to override | — | — |
| Refunds | yes | no | yes |
| Other methods | boleto, card, debit | — | boleto |
Showing the QR
The normalized fields are the same regardless of which gateway served the charge, which is the point:
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ção — ORIGINAL, 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".
Patterns
A cookbook of the flows this library exists for — Pix and subscriptions per gateway, where business logic lives, metered billing, marketplace splits, recovery, chargebacks, and reading the billing data without the bundled console.
Card
A credit card charge without the card ever reaching your server — tokenize in the browser, send the token, and let the webhook decide. The shared shape, then exactly what Stripe and Asaas each map it to, why Asaas demands the holder block, and what happens when you route credit_card to a gateway that has no cards.