Agora
Patterns

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

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

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

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

AsaasWooviStripeAbacatePay
Card subscriptionsyes, tokenizednoyesno
Pix subscriptionsyesPix Automáticoyes
Customerid, requiredinline objectidid + fiscal data
planId meansyour labelyour labela Stripe Price idyour label
First charge needsstartDatestartDate → day of monthtrialDays optional
Reference on each chargepropagatedon the subscriptionno — see belowexternalId, on every event
Cancel at period endno — immediateyes

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.succeededpayment.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:

app/services/subscribe_service.ts
await getPayments().driver('credit_card').updateSubscription(subscription.gatewayId, {
  amount: 9990,
  description: 'Pro → Business',
})

await local.merge({ pendingPlanId: 'plan_business' }).save()
app/payment_handlers/subscription_updated.ts
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.

On this page