3 MIN READ

Building Subscriptions on Medusa: Billing Cycles, Dunning and Churn

Recurring billing is a scheduling problem with money attached. The data model, the renewal workflow, and the failure handling that determines involuntary churn.

BY RAHUL MEHTAUPDATED
Illustration for “Building Subscriptions on Medusa: Billing Cycles, Dunning and Churn” — Subscriptions

Subscriptions are the most requested Medusa feature that Medusa does not ship. They are also the one people most underestimate: the happy path is a scheduled job and a charge, and everything that matters is in the failure handling.

Involuntary churn — subscriptions lost to failed payments rather than customer decisions — is typically the largest single source of subscription loss. Get dunning right and you have built a good subscription system. Get everything else right and skip dunning, and you have built a leaky one.

The data model

src/modules/subscription/models/subscription.tsts
import { model } from "@medusajs/framework/utils"

export const Subscription = model.define("subscription", {
  id: model.id().primaryKey(),
  customer_id: model.text().index(),
  status: model
    .enum(["active", "paused", "past_due", "cancelled", "expired"])
    .default("active"),
  interval: model.enum(["weekly", "monthly", "quarterly", "yearly"]),
  interval_count: model.number().default(1),
  next_billing_at: model.dateTime().index(),
  current_period_start: model.dateTime(),
  current_period_end: model.dateTime(),
  // The provider's stored payment method, not card details.
  payment_method_token: model.text().nullable(),
  payment_provider_id: model.text(),
  failed_attempts: model.number().default(0),
  cancel_at_period_end: model.boolean().default(false),
  items: model.hasMany(() => SubscriptionItem),
})

export const SubscriptionItem = model.define("subscription_item", {
  id: model.id().primaryKey(),
  variant_id: model.text().index(),
  quantity: model.number().default(1),
  unit_price: model.bigNumber(),
  subscription: model.belongsTo(() => Subscription, { mappedBy: "items" }),
})

next_billing_at is indexed because the renewal job queries on it every hour. failed_attempts drives dunning. Store the provider's token, never card details — PCI scope is not something to acquire by accident.

The renewal workflow

src/workflows/renew-subscription.tsts
import { createWorkflow, WorkflowResponse, transform } from "@medusajs/framework/workflows-sdk"
import { chargeSubscriptionStep } from "./steps/charge-subscription"
import { createSubscriptionOrderStep } from "./steps/create-subscription-order"
import { advanceBillingPeriodStep } from "./steps/advance-billing-period"

export const renewSubscriptionWorkflow = createWorkflow(
  "renew-subscription",
  (input: { subscription_id: string }) => {
    const payment = chargeSubscriptionStep({ subscription_id: input.subscription_id })

    const order = createSubscriptionOrderStep({
      subscription_id: input.subscription_id,
      payment_id: payment.id,
    })

    const advanced = advanceBillingPeriodStep({ subscription_id: input.subscription_id })

    return new WorkflowResponse({ order, advanced })
  }
)

Order matters. Charge first, then create the order, then advance the period. If order creation fails, the charge is refunded by its compensation and the period is not advanced — so the next run retries cleanly rather than skipping a cycle the customer paid for.

Workflows and compensation.

The scheduler

src/jobs/process-renewals.tsts
import { MedusaContainer } from "@medusajs/framework/types"
import { SUBSCRIPTION_MODULE } from "../modules/subscription"
import { renewSubscriptionWorkflow } from "../workflows/renew-subscription"

export default async function processRenewals(container: MedusaContainer) {
  const subscriptions = container.resolve(SUBSCRIPTION_MODULE)
  const logger = container.resolve("logger")

  const due = await subscriptions.listSubscriptions({
    status: ["active", "past_due"],
    next_billing_at: { $lte: new Date() },
  })

  for (const subscription of due) {
    try {
      await renewSubscriptionWorkflow(container).run({
        input: { subscription_id: subscription.id },
      })
    } catch (error) {
      // One failure must not stop the batch.
      logger.error(`[renewals] ${subscription.id}: ${(error as Error).message}`)
      await handleFailedPayment(container, subscription)
    }
  }
}

export const config = {
  name: "process-renewals",
  schedule: "0 * * * *",
}

Hourly, not daily — it spreads load, tolerates a missed run, and lets you honour billing times rather than billing everyone at midnight. Never let one subscription's failure abort the batch.

This runs on worker instances only, which is why server and worker separation matters: without it, every web replica bills every subscriber.

Dunning

The part that determines revenue.

AttemptDelayAction
1On the dayCharge; email on failure
2+3 daysRetry; email with an update-payment link
3+5 daysRetry; warn about suspension
4+7 daysFinal retry; suspend on failure

Why this works: a large share of declines are temporary — expired cards, insufficient funds, issuer noise — and simply retrying on a different day recovers many of them. The rest recover because you asked the customer to update their card.

Three details that raise recovery meaningfully:

  • Retry at a different time of day. Insufficient-funds declines resolve around pay dates.
  • Email a direct update link, not a generic "there was a problem" message.
  • Handle expiring cards proactively — email before the expiry date, not after the decline.

Pause, skip, cancel

Give customers the intermediate options. A customer who cannot pause will cancel:

  • Pause — status paused, no billing, resumable with a defined return date.
  • Skip — advance next_billing_at by one interval without charging.
  • Cancel at period end — cancel_at_period_end, keeping access to the end of the paid period.
  • Cancel immediately — with or without a pro-rata refund, per your policy.

Offering pause and skip prominently reduces cancellations measurably. It is the cheapest retention feature available.

Payment tokens and migrations

The reason subscriptions dominate replatforming timelines: tokens are bound to a specific gateway and merchant account and are usually not portable. Migrating subscribers means re-enrolling them.

If you are planning a migration, resolve this in week one. Ask the processor directly whether tokens can move; if not, plan a cohort migration with a real incentive to re-authorise, and expect some loss. Shopify migration specifics.

In India, recurring collection requires an e-mandate rather than a stored card. Razorpay integration.

Practical guidance

Idempotency keys per subscription and period. Retries must never double-charge. Derive the key from subscription id plus period start.

Store the price on the subscription item. Base prices change; existing subscribers should not be repriced silently.

Notify before charging. A pre-billing email reduces disputes and is required by some regulations.

Test clock skew and month ends. The 31st of a month has fewer equivalents than you think.

Metrics that matter

Subscription businesses live or die on a small number of numbers. Instrument them from the start:

MetricDefinitionWatch for
MRRSum of normalised monthly value of active subsThe headline
Involuntary churnCancellations from failed paymentsUsually fixable
Voluntary churnCustomer-initiated cancellationsProduct signal
Recovery rateFailed payments recovered by dunningTarget 50%+
Cohort retention% of a signup month still activeThe truth
Card expiry exposureActive subs with cards expiring in 60 daysPrevent, do not react

The last one is the cheapest win available. Query it monthly and email those customers before the decline, rather than running dunning after it.

Proration and plan changes

An upgrade or downgrade mid-period needs a decision, and it should be a policy rather than an implementation accident:

  • Immediate with proration. Charge or credit the difference for the remaining days. Fairest and most complex.
  • Immediate, no proration. New price applies now, no adjustment. Simple, and generates complaints on downgrades.
  • At next renewal. Change takes effect at the period end. Simplest, and the safest default.

For most subscription products, upgrades immediately with proration and downgrades at period end is the combination that reads as fair while protecting revenue. Whichever you choose, show the customer the exact amount and date before they confirm — surprise proration charges are a chargeback source.

Store the applied price on the subscription item so existing subscribers are never silently repriced by a change to the base product.


Subscriptions are the highest-value and highest-risk thing we build on Medusa. Ask for a scope.

Frequently asked questions

Does Medusa support subscriptions natively?

No. Subscriptions are built as a custom module with a scheduled renewal job and a billing workflow. That is more work than installing an app, and it means the billing rules are yours rather than an app vendor's.

How do I handle failed subscription payments?

With dunning: retry on a schedule of roughly three, five and seven days, at different times of day, with escalating emails containing a direct payment-update link. Most declines are temporary, so retries plus communication recover a large share.

How often should the renewal job run?

Hourly. It spreads load, tolerates a missed run, and lets you bill at the customer's original time rather than processing everyone at midnight. Ensure it runs only on worker instances.

Can I migrate subscriptions from another platform?

Rarely without customer action. Payment tokens are bound to the original gateway and merchant account, so subscribers usually have to re-enter payment details. Confirm portability with your processor before committing to a migration timeline.

How do I stop double-charging on retries?

Use an idempotency key derived from the subscription id and the billing period, and pass it to the payment provider. Combined with workflow compensation, that ensures a retry after a partial failure does not take money twice.

Should customers be able to pause a subscription?

Yes. Pause and skip are the cheapest retention features available — a customer who cannot pause will often cancel instead. Make both easy to find rather than hiding them behind support.

[ Keep reading ]