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.
The card number never touches your server. The browser sends it straight to the gateway's
tokenizer — Stripe.js, or Asaas' tokenization endpoint — and gets back an opaque string. That string
is the only thing your controller ever sees, the only thing you send to charge(), and the only
thing worth logging. Every example on this page starts from a token that already exists.
This is not a style preference. A PAN that reaches your process reaches your logs, your APM, your error tracker and your database backups, and it drags all of them into PCI scope. Tokenize in the browser and the gateway carries that weight instead.
// Never — this is a PCI incident with extra steps
await driver.charge({ amount: 4990, metadata: { number: '4111...', cvv: '123' } })
// The token the browser got back
await driver.charge({ amount: 4990, card: { token: cardTokenFromFrontend } })The shape that is the same everywhere
import { getPayments } from '@adonis-agora/payments/services/main'
export async function chargeCard(user: User, plan: Plan, cardToken: string, ip: string) {
const driver = getPayments().driver('credit_card')
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
method: 'credit_card',
description: plan.name,
externalReference: order.id, // ← how the webhook finds this order
idempotencyKey: `order:${order.id}`,
card: {
token: cardToken, // tokenized in the browser
remoteIp: ip,
},
})
return { order, payment }
}payment.status is 'pending'. A card charge is not settled because the API call returned — it is
settled when the gateway says so on your webhook. See
The payment lifecycle.
card.token or paymentMethodId — pick one
ChargeInput accepts both. They mean the same thing and both land on the same gateway field, but
the two drivers resolve a collision in opposite directions: Stripe applies paymentMethodId
first and lets card.token overwrite it; Asaas applies card.token first and lets paymentMethodId
overwrite it. Passing both is how you charge a card you did not mean to. Pass one.
Prefer card — it is the only one that carries holder and remoteIp.
What each gateway needs
Showing Stripe — pick your gateway in the sidebar to see yours.
The token becomes payment_method on the PaymentIntent. That is the whole mapping — either
input reaches the same field:
await getPayments().driver('credit_card').charge({
customerId: user.stripeCustomerId!,
amount: 4990,
description: 'Plano Pro',
externalReference: order.id,
paymentMethodId: 'pm_1QAbc...', // → payment_method
// …or, identically:
// card: { token: 'pm_1QAbc...' }, // → payment_method
})No holder block, no IP. Stripe already holds the billing details on the PaymentMethod you
created in the browser, so card.holder and card.remoteIp are accepted by the type and ignored by
this driver. They are not an error — they simply go nowhere. Set the billing details when you create
the PaymentMethod client-side.
Your reference goes into metadata, not a first-class field: externalReference →
metadata.external_reference, and idempotencyKey → metadata.idempotency_key. Read it back from
there in the webhook rather than parsing the description.
The intent is created, not confirmed. The driver calls paymentIntents.create and returns
whatever status Stripe hands back, which it normalizes like this:
Stripe intent.status | normalized payment.status |
|---|---|
succeeded | paid |
requires_payment_method, requires_action, processing | pending |
canceled | canceled |
anything else (incl. requires_confirmation) | failed |
Do not act on the returned status
An intent that still needs confirmation, or a 3-D Secure step, is not a decline — but it does not
normalize to pending either. Confirm the intent in your Stripe.js flow, and let the
payment.succeeded / payment.failed webhook be the thing your business logic listens to. This is
the same rule as everywhere else in this library; cards just make it easier to get wrong.
Currency falls back to the driver's configured currency, which Stripe requires you to set — pass
currency on the charge when a single app bills in more than one.
The differences at a glance
| Stripe | Asaas | |
|---|---|---|
card.token lands in | payment_method | creditCardToken |
paymentMethodId lands in | payment_method | creditCardToken |
| Which wins when both are passed | card.token | paymentMethodId |
card.holder | ignored | → creditCardHolderInfo, all 6 fields required |
card.remoteIp | ignored | → remoteIp (the payer's IP) |
| Customer required | no | yes |
| Your reference lands in | metadata.external_reference | externalReference |
| Method is | explicit payment_method_types on the intent | inferred → billingType: CREDIT_CARD |
| Debit card | no | yes (debit_card) |
| Refunds | yes | yes |
Routing credit_card
Your service asks for a method, never a gateway, so the choice of card processor is one line of config:
export default defineConfig({
default: 'asaas',
providers: {
stripe: payments.stripe({ apiKey: env.get('STRIPE_KEY'), currency: 'brl' }),
asaas: payments.asaas({ apiKey: env.get('ASAAS_API_KEY') }),
woovi: payments.woovi({ appId: env.get('WOOVI_APP_ID') }),
},
methods: {
pix: 'woovi',
credit_card: 'stripe', // ← swap to 'asaas' and nothing else changes
boleto: 'asaas',
undefined: 'asaas',
},
})getPayments().driver('credit_card') // → whichever provider methods.credit_card namesWhich gateways credit_card can be routed to
Cards are the majority now. Eleven of the eighteen drivers name credit_card in
supportedMethods and can be routed it directly: Stripe, Adyen, Square, Mollie, Dodo, Polar,
Asaas, Pagar.me, PagBank, Mercado Pago and InfinitePay.
Two groups cannot, for different reasons:
Four have no cards. Woovi/OpenPix is Pix-only, AbacatePay does Pix and boleto, Efí is Pix only,
and PayPal's driver declares wallet. Routing credit_card to any of them is a config error the
manager catches when you ask for the driver, before a byte reaches the gateway.
Three take cards but refuse to promise one. Paddle, Lemon Squeezy and Razorpay declare
supportedMethods of ['undefined'] only. That is not an omission: on a merchant-of-record
checkout or a Razorpay Order, nothing in the API can promise the payer will choose a card rather
than PayPal or UPI, so the driver will not claim it. Route undefined to them and read the method
the payer actually chose off the webhook — payment.method on Razorpay, the equivalent field on the
others.
The refusal looks the same either way:
// methods: { credit_card: 'woovi' }
getPayments().driver('credit_card')[payments] Driver "woovi" does not support payment method "credit_card".
Supported methods: pix, undefined.
Route "credit_card" to a different provider in config.methods.// methods: { credit_card: 'abacate' }
getPayments().driver('credit_card')[payments] Driver "abacate" does not support payment method "credit_card".
Supported methods: pix, boleto, undefined.
Route "credit_card" to a different provider in config.methods.Naming the gateway skips the check — and skips the method
The guard fires only when you route a method. getPayments().driver('woovi') hands you the
Woovi driver with no method to validate, and the failure moves to the gateway's own error later.
driver('credit_card') does one more thing a provider name does not: it returns the driver
bound to that method, so charge() and createSubscription() get method: 'credit_card'
filled in without you repeating it. That matters here, because every driver that varies by method
reads it off the charge — Stripe's payment_method_types, Asaas' billingType — and without it the
charge falls back to whatever the gateway's dashboard defaults are. A method you pass explicitly
still wins — unless config.methods routes it somewhere else, in which case the call is refused
rather than sent to the wrong gateway. Routing is a default, not an override, and never a
contradiction. See Routing.
Forgetting the routing entry altogether is its own error, and it lists what is available:
[payments] "credit_card" is neither a configured provider nor a method routed in
config.methods. Configured providers: stripe, asaas, woovi. Known methods: pix,
credit_card, debit_card, boleto, undefined.Declines arrive on the webhook
A card that is refused does not throw at charge() — the charge is accepted, and the refusal comes
back as payment.failed, exactly like every other outcome in this library:
export default class PaymentFailedHandler {
static readonly eventType = 'payment.failed'
async handle(event: WebhookEventFor<'payment.failed'>): Promise<void> {
const data = event.data // PaymentWebhookData — no cast
const order = await Order.findBy('id', data.externalReference)
if (!order) return
await order.merge({ status: 'payment_failed' }).save()
// `reason` is optional and usually absent — only the Adyen driver normalizes one.
// The decline code is on `event.raw` under the gateway's own field name.
await this.mail.send(new CardDeclined(order, data.reason))
}
}Do not suspend or cancel on the first decline. Cards expire, issuers refuse transiently, and a limit resets tomorrow — retry with escalation instead. The dunning ladder lives in Recovering, and where handlers belong is Reacting to payments.
A decline is not the card failure that costs the most. That one is the chargeback, which arrives weeks after the charge settled and takes the money back — and on the gateways that send a pre-dispute alert, there is a window where refunding stops it being filed at all. See Disputes.
Refunds
Most card gateways support refunds — but not all, and the code that offers the button must not
assume. InfinitePay declares refunds: false while happily taking credit_card, so a routing
change from Stripe to InfinitePay turns a working refund button into a gateway error. Capability-
check first, at your own boundary:
const payments = getPayments()
const driver = payments.driver(payment.provider)
payments.assertCapability(driver, 'refunds')
const refund = await driver.refund(payment.gatewayId, partialAmountCents)refund(gatewayId) refunds in full; an amount in cents refunds partially. And, as always, the
refund confirms by webhook — revoke access on payment.refunded, not on the API response. See
Recovering.
Recurring cards
Everything above is a one-off charge. A card that should be charged every month is a different call
— createSubscription() takes the same CardInput, so the gateway keeps the token and re-charges
it each cycle without you storing anything. See
Subscriptions.
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.
Subscriptions
Recurring billing per gateway — tokenized cards on Asaas and Stripe, Pix Automático on Woovi, what each one needs to actually start charging, plus trials, upgrades and cancellation.