Most SaaS products do not need a “crypto payment button.”

They need a payment module.

That distinction matters.

A button can redirect a customer to a payment page.

A module has to know which user is paying, which workspace should be upgraded, which plan should become active, when access should expire, how failed or expired payments should be handled, how support can inspect payment status, and how finance can export records later.

That is where developers can build a serious product.

A Crypto Payment Module for SaaS Apps is a reusable layer that lets SaaS builders add crypto payments without building the whole payment lifecycle from scratch.

In this article, I will use OxaPay as the example crypto payment infrastructure because its documentation exposes the primitives needed for this kind of module: invoice generation, white-label payments, static addresses, webhooks, payment information, payment history, SDKs for PHP, Python and Laravel, and automation integrations.

This is not a “get rich with crypto APIs” article.

It is a practical blueprint for developers who want to build something SaaS founders, indie hackers, agencies, and product teams may actually pay for.


The core idea

A Crypto Payment Module gives SaaS apps a production-ready way to accept crypto payments and translate payment events into SaaS account states.

Instead of selling this:

I can integrate crypto payments into your app.

You sell this:

I can give your SaaS a reusable crypto billing module with invoices, payment status tracking, webhook verification, plan activation, grace periods, admin tools, and payment history sync.

That is a much stronger offer.

A SaaS founder does not only care that a payment happened.

They care that the right account is upgraded, the right plan is applied, the right billing period is extended, the right user sees the right status, and the support team can understand what happened when something goes wrong.

That is what your module should solve.


Why this is a real developer business opportunity

Many SaaS builders are comfortable shipping product features but do not want to become payment infrastructure developers.

Crypto payments create extra operational questions:

  • Which invoice belongs to which user?
  • What happens if the invoice expires?
  • What happens if the customer pays late?
  • What if the payment is confirming but not paid yet?
  • Should access be granted immediately or only after final paid status?
  • How do we avoid activating the same plan twice if the webhook is retried?
  • How do we show payment history inside the SaaS dashboard?
  • How does support investigate a customer who says they paid?
  • How do we export crypto payment records for operations or finance?

A payment provider gives you the payment primitives.

Your module turns those primitives into SaaS business logic.

That is where the value is.


Who would pay for this?

This idea is not for every business.

It is strongest for SaaS products that already have global users, developer-heavy users, creator communities, digital services, or customers who prefer stablecoin or crypto payment options.

Potential buyers include:

  • indie SaaS founders
  • micro-SaaS builders
  • AI tool developers
  • hosting panels
  • VPN and proxy panels
  • API-as-a-service platforms
  • software license sellers
  • B2B SaaS tools with international customers
  • Telegram or Discord-based SaaS communities
  • agencies building SaaS products for clients
  • Laravel, Node.js, Django, or Next.js SaaS boilerplate sellers

The buyer is not paying for “crypto.”

They are paying for a shorter path from payment to active subscription.


What you are building

The product can be packaged in several ways.

You can build:

  1. A framework module

    Example: Laravel package, Node.js module, Django app, or Next.js starter component.

  2. A SaaS billing add-on

    A hosted service that SaaS apps connect to through API keys and webhooks.

  3. A boilerplate feature pack

    A paid template that includes crypto checkout, webhook handling, billing state, and admin screens.

  4. A custom implementation service

    A productized service for SaaS founders who want crypto payments added to their app.

  5. Open-source core plus paid support

    A free module with paid implementation, hosted dashboards, or premium integrations.

The technical foundation is similar in all versions.

Your module needs to:

  • create a crypto invoice for a selected SaaS plan
  • store the payment session locally
  • attach the payment to a user, workspace, plan, and billing period
  • receive and verify payment webhooks
  • update subscription state only after the correct payment status
  • handle retries and duplicate events safely
  • allow support to inspect payment records
  • optionally sync payment history for backfill and reconciliation
  • expose UI components or API endpoints the SaaS app can use

The OxaPay primitives you can use

OxaPay is useful as an example because the documentation provides several building blocks that map well to SaaS payment modules.

Generate Invoice

OxaPay’s Generate Invoice endpoint creates a new invoice and returns a payment URL. The request can include fields such as amount, currency, lifetime, callback URL, return URL, email, order ID, and description depending on the payment flow.

For a SaaS module, this is the default primitive for one-time plan payments, renewals, upgrades, and invoice-based subscription periods.

Generate White Label

OxaPay’s White Label endpoint returns payment details such as address, payment amount, currency, network, QR code, expiration time, and related information so the developer can build the payment interface inside their own app instead of redirecting the user to a hosted invoice page.

This is useful if your SaaS module needs an embedded checkout experience.

Generate Static Address

OxaPay’s Static Address endpoint can generate a reusable address linked to a track ID. If a callback URL is provided, the merchant server can receive notifications for payments made to that address. OxaPay notes that static addresses with no transactions for six months may be revoked.

For SaaS apps, static addresses can be useful for wallet balance top-ups, account credit systems, or long-lived customer deposit flows. They should not be treated as a replacement for all subscription logic.

Webhook

OxaPay webhooks send JSON notifications to the configured callback URL when payment status changes. The docs explain that merchants should validate the callback signature using HMAC SHA-512 over the raw POST body, with the signature sent in an HMAC header.

This is one of the most important parts of the SaaS module.

Payment Information

The Payment Information endpoint retrieves the details of a specific payment using its track_id.

For SaaS products, this is useful when support needs to inspect a payment or when your module needs to verify a payment state outside the webhook path.

Payment History

The Payment History endpoint returns payment records and supports pagination. This is useful for backfill jobs, admin reports, reconciliation, and finding missed webhook events.

SDKs

OxaPay provides SDKs for PHP, Python, and Laravel. The Laravel SDK is especially relevant if your target market includes Laravel SaaS builders. The SDK documentation lists available methods such as generateInvoice, generateWhiteLabel, generateStaticAddress, payment information, payment history, and webhook verification.

References:


Important limitation: this is not automatic card-style recurring billing

This point is critical.

A crypto payment module should not pretend to work like card subscriptions where the merchant can automatically charge the customer every month without customer action.

In most crypto payment flows, the customer still needs to send a payment or complete a new invoice.

So your module should define subscription logic like this:

  • create invoice for first billing period
  • activate plan after confirmed paid status
  • calculate current_period_start and current_period_end
  • send renewal reminders before expiry
  • create renewal invoice when needed
  • apply grace period if payment is late
  • downgrade or suspend access if renewal does not happen

That is not a weakness.

It is simply the correct model.

Your module owns the subscription state.

OxaPay provides payment infrastructure.


The architecture

A production-ready SaaS crypto payment module usually has this flow:

SaaS app
  ↓
User selects plan
  ↓
Payment module creates local checkout session
  ↓
OxaPay invoice or white-label payment is generated
  ↓
Customer pays
  ↓
OxaPay sends webhook to callback URL
  ↓
Module validates HMAC signature
  ↓
Module stores event idempotently
  ↓
Module updates payment session
  ↓
Module activates or extends SaaS subscription
  ↓
Admin dashboard and user dashboard show updated status
  ↓
Backfill job syncs Payment History for missed events

Enter fullscreen mode Exit fullscreen mode

The important thing is that OxaPay is not your database.

Your module should maintain its own internal record of users, plans, invoices, payment sessions, webhook events, subscriptions, and audit logs.


Core module components

A strong module should include these parts.

1. Plan configuration

The SaaS app needs to define plans:

{
  "id": "pro_monthly",
  "name": "Pro Monthly",
  "amount": 29,
  "currency": "USD",
  "billing_interval": "monthly",
  "features": ["projects:50", "team_members:5", "api_calls:100000"]
}

Enter fullscreen mode Exit fullscreen mode

This is your internal product model.

OxaPay should not be the source of truth for SaaS plan permissions.

2. Checkout session

When a user starts payment, create a local checkout session before calling OxaPay.

The session should include:

  • user ID
  • workspace ID
  • plan ID
  • amount
  • currency
  • billing period
  • status
  • provider name
  • provider track ID
  • return URL
  • expiration timestamp

3. Invoice generation

The module creates an OxaPay invoice and stores the returned track_id and payment URL.

4. Webhook receiver

The webhook receiver validates the HMAC signature, stores the event, and updates the payment session.

5. Subscription state machine

Your module updates the SaaS subscription only when the payment reaches the correct paid state.

6. Renewal logic

A scheduled job checks subscriptions that are close to expiration and sends renewal reminders or generates new invoices.

7. Backfill and reconciliation

Payment History can be used to backfill records and detect missed webhook events.

8. Admin dashboard

SaaS admins need to inspect payment status, subscription state, webhook logs, failed payments, and customer issues.


Suggested database schema

Here is a practical starting point.

CREATE TABLE saas_plans (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  amount DECIMAL(18, 8) NOT NULL,
  currency TEXT NOT NULL DEFAULT 'USD',
  billing_interval TEXT NOT NULL,
  is_active BOOLEAN NOT NULL DEFAULT true,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE subscriptions (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  workspace_id TEXT,
  plan_id TEXT NOT NULL REFERENCES saas_plans(id),
  status TEXT NOT NULL,
  current_period_start TIMESTAMP,
  current_period_end TIMESTAMP,
  grace_until TIMESTAMP,
  cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE crypto_checkout_sessions (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  workspace_id TEXT,
  subscription_id TEXT REFERENCES subscriptions(id),
  plan_id TEXT NOT NULL,
  amount DECIMAL(18, 8) NOT NULL,
  currency TEXT NOT NULL,
  provider TEXT NOT NULL DEFAULT 'oxapay',
  provider_track_id TEXT UNIQUE,
  provider_order_id TEXT UNIQUE,
  payment_url TEXT,
  status TEXT NOT NULL,
  expires_at TIMESTAMP,
  paid_at TIMESTAMP,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE crypto_webhook_events (
  id TEXT PRIMARY KEY,
  provider TEXT NOT NULL DEFAULT 'oxapay',
  provider_track_id TEXT,
  event_type TEXT,
  status TEXT,
  raw_payload JSONB NOT NULL,
  hmac_valid BOOLEAN NOT NULL DEFAULT false,
  processed_at TIMESTAMP,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE subscription_audit_logs (
  id TEXT PRIMARY KEY,
  subscription_id TEXT NOT NULL REFERENCES subscriptions(id),
  action TEXT NOT NULL,
  previous_status TEXT,
  new_status TEXT,
  reason TEXT,
  metadata JSONB,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Enter fullscreen mode Exit fullscreen mode

This schema is intentionally simple.

A production version may add organizations, teams, invoices, credit balances, coupons, taxes, reseller IDs, customer emails, and accounting exports.

But this is enough for an MVP.


Subscription states

Your module should define its own internal subscription states.

Do not expose raw provider statuses directly as SaaS subscription states.

A simple state machine could look like this:

trialing
  ↓
pending_payment
  ↓
active
  ↓
past_due
  ↓
grace_period
  ↓
suspended
  ↓
canceled

Enter fullscreen mode Exit fullscreen mode

Provider payment statuses are inputs.

Subscription states are business decisions.

For example:

  • Paid payment status may activate or extend a subscription.
  • Expired payment status may keep the subscription unchanged if the user already has an active period.
  • Confirming may show “payment detected” but should not unlock high-risk access unless your merchant policy allows it.
  • An invalid webhook should never change subscription state.

This separation is what makes your module safe.


Payment session states

The checkout session also needs its own states.

created
invoice_created
waiting
confirming
paid
expired
failed
review_required
canceled

Enter fullscreen mode Exit fullscreen mode

A payment session is not the same as a subscription.

A user may have many payment sessions for one subscription.

A subscription should only change when a payment session reaches an acceptable terminal state.


Creating an invoice from Node.js

Here is a simplified example using Node.js and Express-style code.

import crypto from "crypto";
import fetch from "node-fetch";
import { db } from "./db.js";

const OXAPAY_API = "https://api.oxapay.com/v1";
const MERCHANT_API_KEY = process.env.OXAPAY_MERCHANT_API_KEY;
const APP_URL = process.env.APP_URL;

function createId(prefix) {
  return `${prefix}_${crypto.randomUUID()}`;
}

export async function createCryptoCheckoutSession(req, res) {
  const user = req.user;
  const { planId } = req.body;

  const plan = await db.saasPlans.findById(planId);
  if (!plan || !plan.is_active) {
    return res.status(400).json({ error: "Invalid plan" });
  }

  const sessionId = createId("cs");
  const orderId = `saas_${sessionId}`;

  await db.cryptoCheckoutSessions.insert({
    id: sessionId,
    user_id: user.id,
    workspace_id: user.workspace_id,
    plan_id: plan.id,
    amount: plan.amount,
    currency: plan.currency,
    provider: "oxapay",
    provider_order_id: orderId,
    status: "created"
  });

  const payload = {
    amount: Number(plan.amount),
    currency: plan.currency,
    lifetime: 60,
    callback_url: `${APP_URL}/webhooks/oxapay/payment`,
    return_url: `${APP_URL}/billing/crypto/return?session_id=${sessionId}`,
    email: user.email,
    order_id: orderId,
    description: `${plan.name} subscription for workspace ${user.workspace_id}`
  };

  const response = await fetch(`${OXAPAY_API}/payment/invoice`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "merchant_api_key": MERCHANT_API_KEY
    },
    body: JSON.stringify(payload)
  });

  const result = await response.json();

  if (!response.ok || result.error) {
    await db.cryptoCheckoutSessions.update(sessionId, {
      status: "failed",
      updated_at: new Date()
    });

    return res.status(502).json({
      error: "Could not create crypto invoice",
      details: result.error || result.message
    });
  }

  const data = result.data;

  await db.cryptoCheckoutSessions.update(sessionId, {
    provider_track_id: data.track_id,
    payment_url: data.payment_url || data.url,
    status: "invoice_created",
    expires_at: data.expired_at ? new Date(data.expired_at * 1000) : null,
    updated_at: new Date()
  });

  return res.json({
    session_id: sessionId,
    payment_url: data.payment_url || data.url,
    track_id: data.track_id
  });
}

Enter fullscreen mode Exit fullscreen mode

The exact response field names can vary by API version or endpoint response shape, so your production code should inspect the actual response from the current docs and store raw provider metadata as well.

The important pattern is this:

  1. create local session first
  2. generate OxaPay invoice second
  3. store provider track ID
  4. redirect user to payment URL
  5. wait for webhook before activating access

Webhook verification with HMAC

The webhook receiver is the most important security boundary.

OxaPay docs explain that webhook callbacks should be validated with HMAC SHA-512 using the raw POST body and the relevant API key as the shared secret. The signature is sent in the HMAC header.

Here is a simplified Express example.

import crypto from "crypto";
import express from "express";
import { db } from "./db.js";
import { activateSubscriptionFromPayment } from "./subscription-service.js";

const app = express();

app.post(
  "/webhooks/oxapay/payment",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const rawBody = req.body;
    const receivedHmac = req.header("HMAC");

    const expectedHmac = crypto
      .createHmac("sha512", process.env.OXAPAY_MERCHANT_API_KEY)
      .update(rawBody)
      .digest("hex");

    const valid =
      receivedHmac &&
      crypto.timingSafeEqual(
        Buffer.from(receivedHmac, "hex"),
        Buffer.from(expectedHmac, "hex")
      );

    const payload = JSON.parse(rawBody.toString("utf8"));

    const eventId = `oxapay_${payload.track_id || payload.trackId}_${payload.status}`;

    await db.cryptoWebhookEvents.insertIgnore({
      id: eventId,
      provider: "oxapay",
      provider_track_id: payload.track_id || payload.trackId,
      event_type: payload.type || "payment",
      status: payload.status,
      raw_payload: payload,
      hmac_valid: Boolean(valid)
    });

    if (!valid) {
      return res.status(401).send("invalid signature");
    }

    await handleOxaPayPaymentEvent(payload);

    return res.status(200).send("ok");
  }
);

async function handleOxaPayPaymentEvent(payload) {
  const trackId = payload.track_id || payload.trackId;

  const session = await db.cryptoCheckoutSessions.findByProviderTrackId(trackId);
  if (!session) {
    await db.reconciliationQueue.insert({
      provider: "oxapay",
      provider_track_id: trackId,
      reason: "unknown_track_id",
      raw_payload: payload
    });
    return;
  }

  const providerStatus = String(payload.status || "").toLowerCase();

  if (providerStatus === "paid") {
    await db.transaction(async trx => {
      const lockedSession = await trx.cryptoCheckoutSessions.lockById(session.id);

      if (lockedSession.status === "paid") {
        return;
      }

      await trx.cryptoCheckoutSessions.update(session.id, {
        status: "paid",
        paid_at: new Date(),
        updated_at: new Date()
      });

      await activateSubscriptionFromPayment(trx, lockedSession);
    });
  }

  if (providerStatus === "expired") {
    await db.cryptoCheckoutSessions.update(session.id, {
      status: "expired",
      updated_at: new Date()
    });
  }

  if (providerStatus === "confirming") {
    await db.cryptoCheckoutSessions.update(session.id, {
      status: "confirming",
      updated_at: new Date()
    });
  }
}

Enter fullscreen mode Exit fullscreen mode

In production, be more defensive.

Handle missing HMAC headers, invalid hex strings, timing-safe comparison length mismatch, malformed JSON, replay protection, duplicate status transitions, and provider response differences.

The central rule is simple:

Never activate SaaS access from an unverified webhook.


Activating or extending the subscription

Here is a simplified subscription activation function.

export async function activateSubscriptionFromPayment(trx, session) {
  const plan = await trx.saasPlans.findById(session.plan_id);

  let subscription = session.subscription_id
    ? await trx.subscriptions.lockById(session.subscription_id)
    : await trx.subscriptions.findActiveOrLatestByUserAndWorkspace(
        session.user_id,
        session.workspace_id
      );

  const now = new Date();

  if (!subscription) {
    subscription = await trx.subscriptions.insert({
      id: `sub_${crypto.randomUUID()}`,
      user_id: session.user_id,
      workspace_id: session.workspace_id,
      plan_id: plan.id,
      status: "active",
      current_period_start: now,
      current_period_end: addBillingInterval(now, plan.billing_interval),
      created_at: now,
      updated_at: now
    });
  } else {
    const baseDate =
      subscription.current_period_end && subscription.current_period_end > now
        ? subscription.current_period_end
        : now;

    await trx.subscriptions.update(subscription.id, {
      plan_id: plan.id,
      status: "active",
      current_period_start: now,
      current_period_end: addBillingInterval(baseDate, plan.billing_interval),
      grace_until: null,
      updated_at: now
    });
  }

  await trx.subscriptionAuditLogs.insert({
    id: `audit_${crypto.randomUUID()}`,
    subscription_id: subscription.id,
    action: "payment_activated_subscription",
    previous_status: subscription.status,
    new_status: "active",
    reason: "oxapay_paid_webhook",
    metadata: {
      checkout_session_id: session.id,
      provider_track_id: session.provider_track_id
    }
  });
}

function addBillingInterval(date, interval) {
  const next = new Date(date);

  if (interval === "monthly") next.setMonth(next.getMonth() + 1);
  else if (interval === "yearly") next.setFullYear(next.getFullYear() + 1);
  else if (interval === "weekly") next.setDate(next.getDate() + 7);
  else throw new Error(`Unsupported billing interval: ${interval}`);

  return next;
}

Enter fullscreen mode Exit fullscreen mode

This is the part SaaS builders usually underestimate.

The payment provider tells you that money was received.

Your module decides what that means for the product.


Renewal logic

A crypto SaaS payment module needs a renewal job.

A simple version runs daily.

export async function runSubscriptionRenewalJob() {
  const now = new Date();
  const threeDaysFromNow = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000);

  const expiringSubscriptions = await db.subscriptions.findMany({
    status: "active",
    current_period_end_before: threeDaysFromNow,
    cancel_at_period_end: false
  });

  for (const subscription of expiringSubscriptions) {
    const existingOpenSession = await db.cryptoCheckoutSessions.findOpenRenewalSession(
      subscription.id
    );

    if (existingOpenSession) continue;

    await createRenewalInvoiceForSubscription(subscription);
  }

  const expiredSubscriptions = await db.subscriptions.findMany({
    status: "active",
    current_period_end_before: now
  });

  for (const subscription of expiredSubscriptions) {
    const graceUntil = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000);

    await db.subscriptions.update(subscription.id, {
      status: "grace_period",
      grace_until: graceUntil,
      updated_at: new Date()
    });
  }

  const graceExpired = await db.subscriptions.findMany({
    status: "grace_period",
    grace_until_before: now
  });

  for (const subscription of graceExpired) {
    await db.subscriptions.update(subscription.id, {
      status: "suspended",
      updated_at: new Date()
    });
  }
}

Enter fullscreen mode Exit fullscreen mode

Renewal is not just a billing concern.

It is a product experience concern.

A good module should provide hooks like:

  • onRenewalInvoiceCreated
  • onPaymentDetected
  • onSubscriptionExtended
  • onSubscriptionPastDue
  • onGracePeriodStarted
  • onSubscriptionSuspended

That lets SaaS apps send emails, in-app notifications, Telegram messages, Discord messages, or CRM updates.


Using Payment History for backfill

Webhook systems are never the only source of truth.

A production module should include a scheduled sync job using Payment History.

This job can:

  • find missed webhook events
  • update sessions stuck in confirming
  • detect payments that were paid but not activated
  • generate daily admin reports
  • help support investigate payment issues
  • reconcile provider records with local records

A simplified backfill process:

Every 15 minutes:
  1. call Payment History with recent date/page filters
  2. for each provider payment:
     - find local checkout session by track_id or order_id
     - compare provider status with local status
     - if provider is paid and local is not active, add to reconciliation queue
     - if provider is expired and local is still pending, update local state
  3. store sync cursor
  4. alert admin if mismatch count exceeds threshold

Enter fullscreen mode Exit fullscreen mode

This is one of the differences between a toy integration and a module people can trust.


Hosted invoice vs white-label checkout

Your module can support two checkout modes.

Hosted invoice mode

The SaaS app redirects the customer to the invoice URL returned by OxaPay.

This is faster to build and easier to support.

Best for:

  • MVPs
  • indie SaaS
  • internal tools
  • lower-budget implementations
  • first version of your module

White-label mode

Your module renders the payment details inside the SaaS app using the response from the White Label endpoint.

This gives a more native UX but requires more frontend and support logic.

Best for:

  • premium SaaS apps
  • branded customer portals
  • agencies
  • high-conversion checkout flows
  • SaaS apps that do not want to redirect users away

A good module can start with hosted invoices and add white-label checkout later.


Static address mode for SaaS wallet credits

Some SaaS apps do not sell fixed subscription periods.

They sell usage credits.

Examples:

  • AI image generation credits
  • API call credits
  • proxy bandwidth credits
  • hosting balance
  • ad campaign balance
  • SMS or email sending credits
  • cloud compute credits

For this model, a static address can be useful.

The module could create a static address per workspace or customer, then credit the customer balance when payment callbacks arrive.

The flow:

User opens billing page
  ↓
Module shows assigned static deposit address
  ↓
User sends crypto payment
  ↓
OxaPay sends callback_url notification
  ↓
Module validates webhook
  ↓
Module credits internal wallet balance
  ↓
User consumes credits inside SaaS app

Enter fullscreen mode Exit fullscreen mode

This is different from subscriptions.

Do not mix the two models unless the product actually needs both.

Subscriptions are time-based access.

Credits are usage-based balance.

Your module can support both, but it should keep them separate internally.


Admin dashboard requirements

A SaaS payment module should include an admin dashboard or at least admin APIs.

Useful screens include:

Billing overview

  • active subscriptions
  • pending crypto invoices
  • paid invoices
  • expired invoices
  • monthly crypto revenue
  • payment method distribution

Customer billing profile

  • current plan
  • subscription status
  • current period end
  • checkout sessions
  • provider track IDs
  • payment history
  • manual actions

Webhook log

  • received events
  • HMAC validity
  • provider status
  • processed/unprocessed state
  • duplicate events
  • errors

Reconciliation queue

  • paid but not activated
  • unknown track ID
  • amount mismatch
  • expired but user claims paid
  • webhook invalid
  • provider status differs from local status

Plan management

  • plan name
  • price
  • interval
  • feature flags
  • active/inactive status

Developers often skip admin screens.

That is a mistake.

Admin visibility is what makes the module sellable to real SaaS operators.


User-facing billing UI

The user-facing side should be simple.

It needs:

  • current plan
  • renewal date
  • subscription status
  • payment button
  • active invoice status
  • payment instructions
  • payment history
  • retry payment action
  • contact support link

A good user-facing status flow could be:

No subscription → Choose plan
Pending payment → Complete payment
Confirming → Payment detected, waiting for confirmation
Active → Plan active until date
Past due → Renewal required
Grace period → Access continues temporarily
Suspended → Pay to restore access
Canceled → Subscription ended

Enter full