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.
A subscription is created at the gateway; the billing layer keeps billing_subscriptions in sync
from the webhooks after that. What you own is creating it correctly — and the gateways differ enough
here that a generic example would be wrong for all of them.
Fourteen of the eighteen drivers declare subscriptions: true. Four do not — Adyen, Efí,
InfinitePay and PagBank have no subscription resource, and assertCapability(driver, 'subscriptions') refuses at your boundary rather than at the gateway. The four worked through below
are the ones whose differences are load-bearing; the rest are on their own
provider pages.
The shape that is the same everywhere
const subscription = await getPayments().driver('credit_card').createSubscription({
customerId: user.billingCustomerId!,
planId: 'plan_pro',
amount: 4990, // integer cents
cycle: 'MONTHLY',
externalReference: `sub:${local.id}`, // ← echoed on the subscription's CHARGES
})Set externalReference on the subscription, not just on charges
A subscription generates a webhook per charge, month after month. Without a reference on the subscription, the charge for month three arrives carrying gateway ids and nothing of yours.
It reaches you on the payment.* events those charges produce. The subscription.* events
themselves do not carry it — route those on gatewayId, and see
Upgrading mid-cycle below.
What each gateway needs
Showing Asaas — pick your gateway in the sidebar to see yours.
Native recurring billing, and the most complete of the four: card, Pix or boleto, with a real transparent-checkout card flow.
const subscription = await getPayments().driver('credit_card').createSubscription({
customerId: user.billingCustomerId!,
planId: 'plan_pro',
amount: 4990,
cycle: 'MONTHLY',
method: 'credit_card',
description: 'Plano Pro — mensal',
startDate: '2026-09-01', // ← becomes nextDueDate
externalReference: `sub:${local.id}`,
card: {
token: cardTokenFromFrontend, // tokenized in the browser
holder: {
name: user.fullName,
email: user.email,
cpfCnpj: user.cpfCnpj,
postalCode: user.postalCode,
addressNumber: user.addressNumber,
phone: user.phone,
},
remoteIp: request.ip(),
},
})startDate is what makes it charge. It maps to Asaas' nextDueDate; without it the subscription
exists and generates nothing. This is the single most common "my subscription never charges" cause —
see Troubleshooting.
The card never touches your server. The frontend tokenizes with Asaas, you send the token plus the holder block the gateway requires (it is also what the NFS-e needs).
externalReference is propagated to every installment. The webhook for the third monthly charge
still carries sub:<id>, so it routes home with no parsing. This is Asaas' best feature for this
library's model.
Cancellation is immediate — Asaas has no period-end flag, so atPeriodEnd is ignored:
await driver.cancelSubscription(subscription.gatewayId)If you sell "access until the end of the period", keep that end date in your own row and enforce it yourself.
Who owns the recurrence
Everything above is gateway mode: the gateway holds the subscription and charges it. That is the default, and for Stripe or Asaas it is usually what you want — their dashboards, their dunning, their retries.
It stops being a choice when the gateway cannot do the job. Woovi/OpenPix has no cancel and no
update: capabilities.subscriptionLifecycle says so, and payments.subscriptions().cancel()
refuses rather than pretending. The way out is managed mode, where this library owns the
recurrence and only ever asks the gateway for a charge — something every gateway can do.
subscriptions: {
mode: 'gateway', // default for everyone
providers: { woovi: 'managed' }, // ...except Woovi, which cannot cancel or update
}Per call wins over per provider, which wins over the global default:
await getPayments().subscriptions().create({ via: 'pix', managed: true, /* ... */ })In managed mode cancelling is a local write, re-pricing takes effect on the next cycle, and every
cycle's charge carries your externalReference — so a renewal reaches your webhook handler
looking like any other payment, with no per-gateway lookup.
Nothing renews on its own. Point a schedule at node ace payments:renew; run it more often than
your shortest cycle. Cycles are keyed idempotently by subscription and period, so an overlapping
run cannot double-charge, and a failed charge stays due instead of skipping the customer's month.
The differences at a glance
| Asaas | Woovi | Stripe | AbacatePay | |
|---|---|---|---|---|
| Card subscriptions | yes, tokenized | no | yes | no |
| Pix subscriptions | yes | Pix Automático | — | yes |
| Customer | id, required | inline object | id | id + fiscal data |
planId means | your label | your label | a Stripe Price id | your label |
| First charge needs | startDate | startDate → day of month | trialDays optional | — |
| Reference on each charge | propagated | on the subscription | no — see below | externalId, on every event |
| Cancel at period end | no — immediate | — | yes | — |
On Stripe, a renewal charge carries no reference
createSubscription writes external_reference into the subscription's metadata, and Stripe
does not copy subscription metadata onto the PaymentIntent that bills each cycle. The driver also
maps no invoice.* events, so month three arrives as payment_intent.succeeded →
payment.succeeded with no externalReference and no subscriptionId on event.data.
Route it on customerId, or on the raw invoice on event.raw. Asaas is the gateway that gets this
right: its externalReference really is propagated to every installment.
Trials
trialDays delays the first charge:
await driver.createSubscription({ customerId, planId: 'plan_pro', amount: 4990, trialDays: 14 })The gateway reports the trial window back on the subscription, and the billing layer mirrors it into
billing_subscriptions.trial_ends_at on subscription.created/updated. Read your own row rather
than recomputing dates — it is what the webhooks keep true.
Upgrading mid-cycle
Update the gateway, then let the webhook confirm before you apply the change locally:
await getPayments().driver('credit_card').updateSubscription(subscription.gatewayId, {
amount: 9990,
description: 'Pro → Business',
})
await local.merge({ pendingPlanId: 'plan_business' }).save()static readonly eventType = 'subscription.updated'
async handle(event: WebhookEventFor<'subscription.updated'>) {
// A subscription event routes on the gateway id, NOT on externalReference — see below.
// `SubscriptionWebhookData`, typed from the event type.
const data = event.data
const local = await Subscription.findBy('gatewayId', data.gatewayId)
if (!local?.pendingPlanId) return
await local.merge({ planId: local.pendingPlanId, pendingPlanId: null }).save()
}Applying the new plan on the API response instead would hand the customer the upgrade even when the gateway rejected the change.
Subscription events do not carry `externalReference`
A subscription.* event's data is a SubscriptionWebhookData: gatewayId, customerId,
status, and optionally planId, trialEndsAt and endsAt. No reference. Every driver maps its
subscription payload onto exactly that shape — Stripe's #subscriptionData and Asaas'
#mapWebhookData both drop it — so a handler reading event.data.externalReference on one gets
undefined and silently returns.
Store the subscription's gatewayId on your own row when you create it and route on that. The
reference is still worth setting: it comes back on the subscription's charges, where
payment.* events do carry it. If you need it on the subscription event itself, it is on
event.raw under the gateway's own field.
Cancelling
const driver = getPayments().driver(subscription.provider)
getPayments().assertCapability(driver, 'subscriptions')
await driver.cancelSubscription(subscription.gatewayId, { atPeriodEnd: true })atPeriodEnd is honoured by Stripe and ignored by Asaas, which cancels immediately. Revoke access on
the subscription.canceled webhook rather than on this call — see
Reacting to payments.
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.
Reacting to payments
Where the business logic lives — the convention folder, billing.handlers in the config, a diagnostics subscriber, or a durable workflow that subscribes itself — what each does when it throws, and how to make the grant safe to run twice.