Build a SaaS Billing System with Next.js, Stripe, and Fliq
Build a complete SaaS billing flow with trial expiry, dunning emails, and scheduled charge retries using Next.js, Stripe webhooks, and Fliq.
Every SaaS has the same billing headaches: trial expirations, failed payment retries, dunning emails, subscription downgrades. These are all time-based events — things that need to happen at specific moments in the future.
Most teams reach for one of two solutions: a self-hosted job queue (BullMQ, Celery) or a cloud scheduler (AWS EventBridge). Both work, but both add infrastructure you need to manage. If you’re building on Next.js and Vercel, you probably don’t want to run a Redis instance just to remind users their trial is ending.
In this tutorial, we’ll build a complete SaaS billing system using Next.js API routes, Stripe for payments, and Fliq for scheduling all time-based billing events. The entire backend runs on Vercel’s serverless functions — no persistent servers, no job queues.
What we’re building
A billing system with:
- 14-day free trial — auto-expires with a 3-day warning email
- Stripe Checkout for subscription creation
- Failed payment retries — schedule a charge retry 3 days after failure
- Dunning emails — notify users about failed payments with escalating urgency
- Subscription downgrade — auto-downgrade to free after 3 failed retries
All time-based events are handled by Fliq. Stripe handles payments. Next.js handles the logic.
Architecture overview
The key insight: Fliq is the orchestrator. It doesn’t process payments or send emails — it tells your API routes when to do those things.
The flow looks like this:
- User signs up, you create a Stripe customer, and schedule trial expiry/warning via Fliq
- Stripe webhook fires on payment failure, you schedule retry and dunning via Fliq
- Fliq calls your Next.js API routes at the scheduled time to execute the business logic
Project setup
Step 1: Create a Next.js project
npx create-next-app@latest saas-billing --typescript --app --tailwind
cd saas-billing
npm install stripe
Step 2: Configure environment variables
Create .env.local:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
FLIQ_API_TOKEN=fliq_sk_...
NEXT_PUBLIC_APP_URL=http://localhost:3000
Get your Fliq token from fliq.sh/app/settings.
Step 3: Create a Fliq helper
// lib/fliq.ts
const FLIQ_API = "https://api.fliq.sh/v1/jobs";
export async function scheduleJob(params: {
url: string;
method?: string;
body?: Record<string, unknown>;
scheduled_at?: string;
cron?: string;
max_retries?: number;
}) {
const response = await fetch(FLIQ_API, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + process.env.FLIQ_API_TOKEN,
},
body: JSON.stringify({
...params,
method: params.method ?? "POST",
headers: { "Content-Type": "application/json" },
body: params.body ? JSON.stringify(params.body) : undefined,
max_retries: params.max_retries ?? 3,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error("Fliq scheduling failed: " + error);
}
return response.json();
}
Handling user signup and trial start
When a user signs up, we create a Stripe customer and schedule two Fliq jobs: a trial warning email (day 11) and trial expiry (day 14).
// app/api/signup/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { scheduleJob } from "@/lib/fliq";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const { email, name } = await request.json();
const appUrl = process.env.NEXT_PUBLIC_APP_URL!;
const DAY = 24 * 60 * 60 * 1000;
// 1. Create Stripe customer
const customer = await stripe.customers.create({
email,
name,
metadata: { trial_start: new Date().toISOString() },
});
// 2. Save user to your database
// await db.users.create({ email, name, stripeCustomerId: customer.id, plan: "trial" });
// 3. Schedule trial warning email (day 11)
const warningDate = new Date(Date.now() + 11 * DAY);
await scheduleJob({
url: appUrl + "/api/billing/trial-warning",
body: { customerId: customer.id, email, name },
scheduled_at: warningDate.toISOString(),
});
// 4. Schedule trial expiry (day 14)
const expiryDate = new Date(Date.now() + 14 * DAY);
await scheduleJob({
url: appUrl + "/api/billing/trial-expired",
body: { customerId: customer.id, email, name },
scheduled_at: expiryDate.toISOString(),
});
return NextResponse.json({
customerId: customer.id,
trialEnds: expiryDate.toISOString(),
});
}
Trial warning and expiry endpoints
These are the endpoints Fliq will call when the scheduled time arrives:
// app/api/billing/trial-warning/route.ts
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const { customerId, email, name } = await request.json();
// Check if user already upgraded (idempotency)
// const user = await db.users.findByStripeId(customerId);
// if (user.plan !== "trial") return NextResponse.json({ skipped: true });
// Send trial warning email via Resend, SendGrid, etc.
console.log("Sent trial warning to " + email);
return NextResponse.json({ sent: true });
}
// app/api/billing/trial-expired/route.ts
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const { customerId, email } = await request.json();
// Check if user already upgraded
// const user = await db.users.findByStripeId(customerId);
// if (user.plan !== "trial") return NextResponse.json({ skipped: true });
// Downgrade to free plan
// await db.users.update(customerId, { plan: "free" });
console.log("Trial expired for " + email + ", downgraded to free");
return NextResponse.json({ downgraded: true });
}
Handling failed payments with Stripe webhooks
When a payment fails, Stripe sends a webhook. We listen for it and schedule a retry and a dunning email via Fliq:
// app/api/webhooks/stripe/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { scheduleJob } from "@/lib/fliq";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const body = await request.text();
const sig = request.headers.get("stripe-signature")!;
const appUrl = process.env.NEXT_PUBLIC_APP_URL!;
const DAY = 24 * 60 * 60 * 1000;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
if (event.type === "invoice.payment_failed") {
const invoice = event.data.object as Stripe.Invoice;
const customerId = invoice.customer as string;
const attemptCount = invoice.attempt_count ?? 1;
// Schedule payment retry in 3 days
if (attemptCount < 3) {
await scheduleJob({
url: appUrl + "/api/billing/retry-payment",
body: {
customerId,
invoiceId: invoice.id,
attempt: attemptCount + 1,
},
scheduled_at: new Date(Date.now() + 3 * DAY).toISOString(),
max_retries: 2,
});
}
// Schedule dunning email (1 minute from now)
await scheduleJob({
url: appUrl + "/api/billing/dunning-email",
body: {
customerId,
invoiceId: invoice.id,
attempt: attemptCount,
},
scheduled_at: new Date(Date.now() + 60 * 1000).toISOString(),
});
// After 3 failed attempts, schedule downgrade with 1-day grace
if (attemptCount >= 3) {
await scheduleJob({
url: appUrl + "/api/billing/downgrade",
body: { customerId },
scheduled_at: new Date(Date.now() + 1 * DAY).toISOString(),
});
}
}
return NextResponse.json({ received: true });
}
Payment retry endpoint
// app/api/billing/retry-payment/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const { invoiceId, attempt } = await request.json();
try {
const invoice = await stripe.invoices.pay(invoiceId);
console.log("Payment retry #" + attempt + " succeeded for " + invoiceId);
return NextResponse.json({ paid: true, status: invoice.status });
} catch (error) {
console.log("Payment retry #" + attempt + " failed for " + invoiceId);
// Fliq will retry this endpoint if we return a 5xx
return NextResponse.json(
{ paid: false, error: "Payment failed" },
{ status: 502 }
);
}
}
The complete billing timeline
Here’s what the billing flow looks like for a user who signs up, ignores the trial warning, and has a failed payment:
| Day | Event | Triggered by |
|---|---|---|
| 0 | User signs up, trial starts | User action |
| 11 | Trial warning email sent | Fliq scheduled job |
| 14 | Trial expired, downgraded to free | Fliq scheduled job |
| 14 | User upgrades to paid plan | User action |
| 45 | Payment fails (card expired) | Stripe |
| 45 | Dunning email #1 sent | Fliq (via webhook) |
| 48 | Payment retry #2 | Fliq scheduled job |
| 48 | Dunning email #2 sent | Fliq (via webhook) |
| 51 | Payment retry #3 — fails | Fliq scheduled job |
| 52 | Account downgraded to free | Fliq scheduled job |
Every time-based event is a Fliq job. Every Fliq job hits a Next.js API route. Every API route is a stateless serverless function. No queues, no cron servers, no Redis.
Monitoring everything
The beauty of this approach is visibility. Every scheduled job shows up in the Fliq dashboard:
- See all pending jobs for a customer
- Check if a retry succeeded or failed
- View the full request/response for each execution
- Cancel pending jobs if a user upgrades before the trial warning fires
Cost breakdown
For a SaaS with 10,000 users:
- ~10,000 trial warning jobs/month
- ~10,000 trial expiry jobs/month
- ~2,000 payment retry jobs/month (estimating 20% churn)
- ~2,000 dunning email jobs/month
Total: ~24,000 executions/month — free during the beta (100,000/day), and ~$0.24/month at the pay-as-you-go rate of $1 per 100,000 executions.
Compare that to running a Redis instance ($15-30/month) or managing AWS EventBridge rules.
Try Fliq free — 100,000 executions/day→Key takeaways
- SaaS billing is fundamentally about scheduling — trial expiry, retries, dunning, and downgrades are all time-based events.
- Fliq turns scheduling into API calls — no infrastructure to manage, no state to track.
- Stripe + Fliq + Next.js is a complete serverless billing stack — handles payments, timing, and business logic without persistent servers.
- Always make endpoints idempotent — Fliq (and Stripe) may retry, so your handlers must handle duplicate calls gracefully.
Further reading
- Fliq API reference — full documentation
- How to schedule background jobs in Cloudflare Workers — another Fliq tutorial
- Stripe webhook best practices — Stripe’s official guide
- Fliq pricing — see the full plan comparison