Build a SaaS Billing System with Next.js, Stripe, and Fliq

ErlanMarch 24, 202612 min read

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:

  1. 14-day free trial — auto-expires with a 3-day warning email
  2. Stripe Checkout for subscription creation
  3. Failed payment retries — schedule a charge retry 3 days after failure
  4. Dunning emails — notify users about failed payments with escalating urgency
  5. 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

bash
npx create-next-app@latest saas-billing --typescript --app --tailwind
cd saas-billing
npm install stripe

Step 2: Configure environment variables

Create .env.local:

bash
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

typescript
// 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).

typescript
// 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:

typescript
// 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 });
}
typescript
// 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:

typescript
// 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

typescript
// 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:

DayEventTriggered by
0User signs up, trial startsUser action
11Trial warning email sentFliq scheduled job
14Trial expired, downgraded to freeFliq scheduled job
14User upgrades to paid planUser action
45Payment fails (card expired)Stripe
45Dunning email #1 sentFliq (via webhook)
48Payment retry #2Fliq scheduled job
48Dunning email #2 sentFliq (via webhook)
51Payment retry #3 — failsFliq scheduled job
52Account downgraded to freeFliq 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/day

Key takeaways

  1. SaaS billing is fundamentally about scheduling — trial expiry, retries, dunning, and downgrades are all time-based events.
  2. Fliq turns scheduling into API calls — no infrastructure to manage, no state to track.
  3. Stripe + Fliq + Next.js is a complete serverless billing stack — handles payments, timing, and business logic without persistent servers.
  4. Always make endpoints idempotent — Fliq (and Stripe) may retry, so your handlers must handle duplicate calls gracefully.

Further reading

Share

Stay in the loop

Get tutorials, product updates, and tips on serverless infrastructure — delivered to your inbox.

Sign up for free
E

Erlan

Fliq team