Websites12 min read

SaaS Stripe Billing: subscriptions + custom metering (2026)

Mohamed Bah·Fondateur, Kolonell
May 20, 2026
Share:
SaaS Stripe Billing: subscriptions + custom metering (2026)

SaaS Stripe Billing: subscriptions + custom metering (2026)

Websites

Stripe Billing is the SaaS subscriptions standard in 2026. But for B2B SaaS with usage-based pricing, multi-currency Africa, or complex adjustments, you need a custom layer on top. Here's how to architect it cleanly.

TL;DR

- Stripe Billing core: Products + Prices + Subscriptions + Invoices.

- Custom layer: usage-metering, multi-currency, custom dunning, adjustments.

- Wave SN integration via webhooks for pan-AF SaaS.

Stripe Billing + custom architecture

`

[Customer subscribes]

[Stripe Checkout / PaymentMethod]

[Stripe Subscription created (with trial)]

[Webhook subscription.created]

[App creates local Subscription record]

[Usage tracking → Stripe usage records]

[Invoice cycle (monthly)]

[Webhook invoice.payment_succeeded → activate features]

[OR invoice.payment_failed → dunning]

`

Step 1 — data model

`prisma

model Plan {

id String @id @default(cuid())

slug String @unique

name String

description String

stripePriceIdMonthly String

stripePriceIdYearly String?

basePriceXof Int

basePriceEur Int

basePriceUsd Int

currency String @default("EUR")

features Json

metering Json?

}

model Subscription {

id String @id @default(cuid())

organizationId String @unique

organization Organization @relation(fields: [organizationId], references: [id])

planId String

plan Plan @relation(fields: [planId], references: [id])

stripeSubscriptionId String @unique

stripeCustomerId String

status String

currentPeriodStart DateTime

currentPeriodEnd DateTime

trialEnd DateTime?

cancelAtPeriodEnd Boolean

canceledAt DateTime?

invoices Invoice[]

usageRecords UsageRecord[]

}

model UsageRecord {

id String @id @default(cuid())

subscriptionId String

subscription Subscription @relation(fields: [subscriptionId], references: [id])

meterType String

quantity Int

recordedAt DateTime @default(now())

stripeRecordId String?

}

model Invoice {

id String @id @default(cuid())

subscriptionId String

subscription Subscription @relation(fields: [subscriptionId], references: [id])

stripeInvoiceId String @unique

amount Int

currency String

status String

paidAt DateTime?

hostedUrl String

pdfUrl String?

}

`

Step 2 — create subscription

`ts

import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET!);

export async function POST(req: NextRequest) {

const { planId, billingCycle } = await req.json();

const orgId = await getOrgId(req);

const org = await prisma.organization.findUnique({ where: { id: orgId } });

const plan = await prisma.plan.findUnique({ where: { id: planId } });

let stripeCustomerId = org.stripeCustomerId;

if (!stripeCustomerId) {

const customer = await stripe.customers.create({

email: org.email,

name: org.name,

metadata: { organizationId: org.id },

});

stripeCustomerId = customer.id;

await prisma.organization.update({

where: { id: org.id },

data: { stripeCustomerId },

});

}

const session = await stripe.checkout.sessions.create({

mode: 'subscription',

customer: stripeCustomerId,

line_items: [{

price: billingCycle === 'yearly' ? plan.stripePriceIdYearly! : plan.stripePriceIdMonthly,

quantity: 1,

}],

success_url: ${process.env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID},

cancel_url: ${process.env.APP_URL}/billing/canceled,

subscription_data: {

trial_period_days: 14,

metadata: { organizationId: org.id },

},

allow_promotion_codes: true,

});

return Response.json({ checkoutUrl: session.url });

}

`

Step 3 — subscription webhooks

`ts

export async function POST(req: NextRequest) {

const body = await req.text();

Need a professional website?

Kolonell builds websites that attract clients, optimized for the Sénégalese market. Free quote in 2 minutes.

const sig = req.headers.get('stripe-signature')!;

const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_BILLING_WEBHOOK!);

switch (event.type) {

case 'customer.subscription.created':

case 'customer.subscription.updated':

await syncSubscription(event.data.object as Stripe.Subscription);

break;

case 'customer.subscription.deleted':

await markSubscriptionCanceled(event.data.object as Stripe.Subscription);

break;

case 'invoice.payment_succeeded':

await activateInvoice(event.data.object as Stripe.Invoice);

break;

case 'invoice.payment_failed':

await handlePaymentFailure(event.data.object as Stripe.Invoice);

break;

}

return Response.json({ received: true });

}

async function syncSubscription(stripeSub: Stripe.Subscription) {

const orgId = stripeSub.metadata.organizationId;

await prisma.subscription.upsert({

where: { stripeSubscriptionId: stripeSub.id },

create: {

organizationId: orgId,

planId: await getPlanIdFromStripePrice(stripeSub.items.data[0].price.id),

stripeSubscriptionId: stripeSub.id,

stripeCustomerId: stripeSub.customer as string,

status: stripeSub.status.toUpperCase(),

currentPeriodStart: new Date(stripeSub.current_period_start * 1000),

currentPeriodEnd: new Date(stripeSub.current_period_end * 1000),

trialEnd: stripeSub.trial_end ? new Date(stripeSub.trial_end * 1000) : null,

cancelAtPeriodEnd: stripeSub.cancel_at_period_end,

},

update: {

status: stripeSub.status.toUpperCase(),

currentPeriodEnd: new Date(stripeSub.current_period_end * 1000),

cancelAtPeriodEnd: stripeSub.cancel_at_period_end,

},

});

}

`

Step 4 — usage-based metering

For usage features (AI credits, API calls, storage):

`ts

export async function recordUsage(

organizationId: string,

meterType: 'AI_CREDITS' | 'API_CALLS' | 'STORAGE_GB',

quantity: number

) {

const subscription = await prisma.subscription.findUnique({

where: { organizationId },

include: { plan: true },

});

await prisma.usageRecord.create({

data: { subscriptionId: subscription.id, meterType, quantity },

});

const stripeMeterId = subscription.plan.metering[meterType.toLowerCase()].stripeMeterId;

await stripe.billing.meterEvents.create({

event_name: meterType.toLowerCase(),

payload: {

stripe_customer_id: subscription.stripeCustomerId,

value: String(quantity),

},

});

}

await recordUsage(orgId, 'AI_CREDITS', 50);

`

Stripe aggregates usage and bills automatically at period end.

Step 5 — Wave SN multi-currency

For SN customers paying in XOF, Stripe doesn't take Wave. Workaround:

`ts

async function billSnCustomer(subscription) {

const amount = subscription.plan.basePriceXof;

const invoice = await prisma.invoice.create({

data: {

subscriptionId: subscription.id,

amount,

currency: 'XOF',

status: 'OPEN',

hostedUrl: ${APP_URL}/invoices/${invoice.id},

},

});

const paymentLink = await createWavePaymentLink({

amount,

reference: invoice.id,

callbackUrl: ${APP_URL}/api/webhooks/wave-billing,

});

await sendInvoiceEmail(subscription.organization, invoice, paymentLink);

}

`

Step 6 — dunning (failed payment management)

`ts

async function handlePaymentFailure(invoice: Stripe.Invoice) {

const subscription = await prisma.subscription.findUnique({

where: { stripeSubscriptionId: invoice.subscription as string },

include: { organization: true },

});

const failureCount = await getFailureCount(subscription.id);

if (failureCount === 1) {

await sendDunningEmail(subscription, 'first_failure');

} else if (failureCount === 3) {

await sendDunningEmail(subscription, 'urgent');

await sendWhatsApp(subscription.organization.phone, 'dunning_urgent');

} else if (failureCount >= 5) {

await downgradeToFreePlan(subscription);

}

}

`

Real case — Dakar B2B SaaS (450 clients)

MetricValue
MRR14M XOF
Monthly churn4.2%
Failed payments8% (before dunning) → 2% (after)
Trial → paid conversion22%
Average contract value31K XOF/month

FAQ

Q: Stripe Billing vs Paddle?

A: Stripe for control. Paddle = MoR (auto handles VAT/sales tax) but 5-10% fees. For Africa: Stripe.

Q: Free trial mandatory?

A: Not mandatory but 7-14 day recommended. Boosts conversion 30-50%.

Q: Freemium vs trial pricing?

A: Trial = 14 days, freemium = forever free limited. Right choice depends on product. Productivity SaaS = freemium often better.

Conclusion

Stripe Billing custom layer = 2026 sweet spot for serious B2B SaaS. 2-4 weeks setup + ongoing maintenance. ROI: pricing agility + controlled churn + robust multi-currency.

Tags:#SaaS#Stripe Billing#Subscription#Usage Based#B2B#Architecture
Share:

Mohamed Bah

Fondateur, Kolonell

Passionate about digital and entrepreneurship in Africa, Mohamed has been helping Sénégalese businesses with their digital transformation since 2020. Founder of Kolonell, he believes every SME deserves a professional and accessible online présence.