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
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
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.
The scheduler
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.
| Attempt | Delay | Action |
|---|---|---|
| 1 | On the day | Charge; email on failure |
| 2 | +3 days | Retry; email with an update-payment link |
| 3 | +5 days | Retry; warn about suspension |
| 4 | +7 days | Final 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_atby 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:
| Metric | Definition | Watch for |
|---|---|---|
| MRR | Sum of normalised monthly value of active subs | The headline |
| Involuntary churn | Cancellations from failed payments | Usually fixable |
| Voluntary churn | Customer-initiated cancellations | Product signal |
| Recovery rate | Failed payments recovered by dunning | Target 50%+ |
| Cohort retention | % of a signup month still active | The truth |
| Card expiry exposure | Active subs with cards expiring in 60 days | Prevent, 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.
Building a Marketplace on Medusa: Vendors, Splits and Payouts
Multi-vendor commerce means splitting one customer order into several vendor orders, and moving money to people who are not you. The model and the money mechanics.
B2B Commerce on Medusa: Companies, Approvals, Quotes and Net Terms
The four capabilities that separate B2B from DTC, modelled as Medusa modules and workflows — with the data model that makes approvals work.
Testing Medusa: What to Test, What to Skip, and How
Commerce code moves money, so untested checkout logic is a liability. A pragmatic test strategy — what earns its keep, and what is theatre.



