Build a SaaS Billing System with Next.js, Stripe, 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(),
});
}
Why not use Stripe's built-in trial?
Stripe does support trial periods on subscriptions. But scheduling your own trial flow gives you more control — you can send custom warning emails, track trial-to-paid conversion, and handle edge cases like "user started a trial but never added a payment method."
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 }
);
}
}
Returning 5xx triggers Fliq retries
When your endpoint returns a 5xx status, Fliq treats it as a failure and schedules a retry (up to max_retries). Return 2xx to indicate success, even if the business logic "failed" (like a payment decline that you've handled).
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 = $0.24/month on Fliq's Growth plan.
Compare that to running a Redis instance ($15-30/month) or managing AWS EventBridge rules.
Try Fliq free — 5,000 executions/dayKey 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
Stay in the loop
Get tutorials, product updates, and tips on serverless infrastructure — delivered to your inbox.
Sign up for freeErlan
Fliq team
Related posts
How to Handle Stripe API Rate Limits (429 Errors)
Stripe returns 429 when you call it too fast — and bulk jobs across multiple workers hit it easily. Here's how to pace your writes to Stripe without a 429.
Fixing Shopify API Rate Limits (2 Calls Per Second)
"Exceeded 2 calls per second for api client" is the Shopify error every bulk sync hits. Here's how to pace your writes to Shopify and stop the 429s.
How to Schedule Background Jobs in Cloudflare Workers (Without Durable Objects)
Learn how to schedule HTTP callbacks, cron jobs, and retries in Cloudflare Workers without Durable Objects — using one API call.