Subscription Commerce Technical Architecture — Beyond Recurring Billing
Subscriptions Are Hard
A DTC brand launched a subscription box. They integrated Stripe Billing and called it done. Within 6 months they discovered: customers wanted to skip months, swap products, change frequency, pause during vacations, gift subscriptions to friends, and get discounts for annual commitments. Stripe Billing handled the payment part. Everything else required custom engineering.
The Core Architecture
┌─────────────────────────────────────────────────┐
│ Subscription Engine │
├─────────────┬──────────────┬────────────────────┤
│ Plan │ Customer │ Subscription │
│ Management │ Management │ Lifecycle │
├─────────────┼──────────────┼────────────────────┤
│ Billing │ Inventory │ Communication │
│ Engine │ Forecasting │ Engine │
├─────────────┴──────────────┴────────────────────┤
│ Analytics & Churn Prediction │
└─────────────────────────────────────────────────┘
The Subscription Model
interface Subscription {
id: string;
customerId: string;
planId: string;
status: "active" | "paused" | "canceled" | "past_due" | "trialing";
// Billing
billingCycle: "weekly" | "biweekly" | "monthly" | "quarterly" | "annual";
nextBillingDate: Date;
currentPeriodStart: Date;
currentPeriodEnd: Date;
// Product selection
items: SubscriptionItem[];
// Flexibility
skipNextRenewal: boolean;
pausedUntil?: Date;
// Discounts
discountId?: string;
trialEndDate?: Date;
// Metadata
cancelReason?: string;
canceledAt?: Date;
createdAt: Date;
}
interface SubscriptionItem {
productId: string;
variantId: string;
quantity: number;
priceOverride?: number; // Customer-specific pricing
swappable: boolean; // Can customer swap this item?
}The Renewal Pipeline
// Daily job: process upcoming renewals
async function processRenewals() {
const dueSubscriptions = await db.subscriptions.findMany({
where: {
status: "active",
skipNextRenewal: false,
nextBillingDate: { lte: addDays(new Date(), 3) }, // 3-day lookahead
},
});
for (const sub of dueSubscriptions) {
try {
// 1. Validate inventory
const inventoryCheck = await checkInventory(sub.items);
if (!inventoryCheck.allAvailable) {
await handleOutOfStock(sub, inventoryCheck.unavailable);
continue;
}
// 2. Calculate price (discounts, loyalty tiers, etc.)
const price = await calculateSubscriptionPrice(sub);
// 3. Attempt payment
const payment = await chargeCustomer(sub.customerId, price);
if (payment.success) {
// 4. Create order
await createSubscriptionOrder(sub, payment);
// 5. Update next billing date
await advanceBillingCycle(sub);
// 6. Send confirmation
await sendRenewalConfirmation(sub);
} else {
await handleFailedPayment(sub, payment);
}
} catch (error) {
await handleRenewalError(sub, error);
}
}
}Failed Payment Recovery (Dunning)
async function handleFailedPayment(sub: Subscription, payment: PaymentResult) {
const retrySchedule = [
{ daysAfterFailure: 1, action: "retry_payment" },
{ daysAfterFailure: 3, action: "retry_payment_notify" },
{ daysAfterFailure: 5, action: "retry_payment_urgent" },
{ daysAfterFailure: 7, action: "last_attempt" },
{ daysAfterFailure: 10, action: "cancel_subscription" },
];
await db.subscriptions.update({
where: { id: sub.id },
data: {
status: "past_due",
failedPaymentCount: { increment: 1 },
},
});
// Send "update your payment method" email with deep link
await sendDunningEmail(sub, {
updatePaymentUrl: `${BASE_URL}/account/payment?sub=${sub.id}`,
retryDate: addDays(new Date(), 1),
});
}Churn Prevention
// Identify at-risk subscribers using behavioral signals
async function calculateChurnRisk(sub: Subscription): Promise<number> {
const signals = {
// Engagement signals
daysSinceLastLogin: await getLastLoginDays(sub.customerId),
supportTicketsLast30Days: await getTicketCount(sub.customerId, 30),
productSwapsLast3Months: await getSwapCount(sub.id, 90),
skippedRenewals: await getSkipCount(sub.id),
// Payment signals
failedPaymentsLast6Months: await getFailedPayments(sub.id, 180),
// Satisfaction signals
lastNpsScore: await getLatestNps(sub.customerId),
reviewsLeft: await getReviewCount(sub.customerId),
};
// Simple scoring (replace with ML model when you have enough data)
let riskScore = 0;
if (signals.daysSinceLastLogin > 30) riskScore += 20;
if (signals.supportTicketsLast30Days > 2) riskScore += 15;
if (signals.skippedRenewals > 2) riskScore += 25;
if (signals.failedPaymentsLast6Months > 1) riskScore += 20;
if (signals.lastNpsScore !== null && signals.lastNpsScore < 7) riskScore += 20;
return Math.min(riskScore, 100);
}
// Automated retention offers based on risk
async function triggerRetention(sub: Subscription, riskScore: number) {
if (riskScore > 70) {
await sendRetentionOffer(sub, { type: "discount", percent: 20, duration: 3 });
} else if (riskScore > 50) {
await sendRetentionOffer(sub, { type: "free_gift", nextRenewal: true });
} else if (riskScore > 30) {
await sendEngagementEmail(sub, { type: "product_education" });
}
}Key Metrics
Subscription health dashboard:
→ MRR (Monthly Recurring Revenue)
→ Churn rate (monthly and annual)
→ LTV:CAC ratio (target: > 3:1)
→ Average subscription duration
→ Recovery rate (dunning success %)
→ Skip rate (how often customers skip)
→ Swap rate (how often customers change products)
→ NPS score for subscribers vs. one-time buyers
Subscription commerce is a retention game disguised as an acquisition game. The technical architecture should optimize for flexibility (let customers modify their subscription easily), reliability (never miss a renewal or double-charge), and intelligence (predict churn before it happens). Build the billing integration first, but plan for the lifecycle management that makes subscriptions sustainable.