Agora
Patterns

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

app/services/checkout_service.ts
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: externalReferencemetadata.external_reference, and idempotencyKeymetadata.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.statusnormalized payment.status
succeededpaid
requires_payment_method, requires_action, processingpending
canceledcanceled
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

StripeAsaas
card.token lands inpayment_methodcreditCardToken
paymentMethodId lands inpayment_methodcreditCardToken
Which wins when both are passedcard.tokenpaymentMethodId
card.holderignoredcreditCardHolderInfo, all 6 fields required
card.remoteIpignoredremoteIp (the payer's IP)
Customer requirednoyes
Your reference lands inmetadata.external_referenceexternalReference
Method isexplicit payment_method_types on the intentinferred → billingType: CREDIT_CARD
Debit cardnoyes (debit_card)
Refundsyesyes

Routing credit_card

Your service asks for a method, never a gateway, so the choice of card processor is one line of config:

config/payments.ts
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 names

Which 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:

app/payment_handlers/payment_failed.ts
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:

app/services/refund_service.ts
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.

On this page