DEV Community

kevin.s
kevin.s

Posted on

Build a Crypto Payment Module for SaaS Apps

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.

Reference:

https://docs.oxapay.com/api-reference/payment/generate-invoice

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.

Reference:

https://docs.oxapay.com/api-reference/payment/generate-white-label

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.

Reference:

https://docs.oxapay.com/api-reference/payment/generate-static-address

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.

Reference:

https://docs.oxapay.com/webhook

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.

Reference:

https://docs.oxapay.com/api-reference/payment/payment-information

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.

Reference:

https://docs.oxapay.com/api-reference/payment/payment-history

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 fullscreen mode Exit fullscreen mode

Crypto payment UX needs clear language.

Do not make users guess whether they should wait, retry, or contact support.


Framework packaging ideas

The module can be packaged differently depending on your target audience.

Laravel package

This is a strong option because OxaPay has an official Laravel SDK.

Package features:

  • migrations
  • config file
  • webhook route
  • billing middleware
  • subscription model
  • Blade or Inertia components
  • cashier-like helper methods
  • admin routes

Possible API:

$user->createCryptoCheckout('pro_monthly');
$user->cryptoSubscription()->isActive();
$user->cryptoSubscription()->expiresAt();
$user->hasCryptoFeature('api_calls:100000');
Enter fullscreen mode Exit fullscreen mode

Node.js package

Good for Express, NestJS, Fastify, Next.js API routes, or SaaS starters.

Package features:

  • invoice client
  • webhook validator
  • subscription state machine
  • database adapter interface
  • event hooks
  • React billing components

Possible API:

const checkout = await cryptoBilling.createCheckout({
  userId,
  workspaceId,
  planId: "pro_monthly"
});

if (await cryptoBilling.hasActiveSubscription(userId)) {
  // allow access
}
Enter fullscreen mode Exit fullscreen mode

Django app

Good for Python SaaS builders.

Package features:

  • models
  • migrations
  • webhook view
  • admin integration
  • Celery renewal jobs
  • Django signals

Possible API:

checkout = crypto_billing.create_checkout(
    user=request.user,
    plan_id="pro_monthly"
)

if request.user.crypto_subscription.is_active:
    # allow access
Enter fullscreen mode Exit fullscreen mode

Next.js starter module

Good for indie hackers and SaaS boilerplate sellers.

Package features:

  • billing page
  • API route for checkout creation
  • webhook route
  • Prisma schema
  • React components
  • admin page
  • cron route for renewal reminders

This may be the easiest product to sell as a paid template.


MVP scope

Do not start by building every billing feature.

Start with one clear use case:

Let a SaaS user pay for a monthly plan with crypto and activate access after confirmed payment.

A good MVP includes:

  • plan table
  • checkout session table
  • subscription table
  • OxaPay invoice creation
  • payment webhook receiver
  • HMAC verification
  • idempotent event handling
  • subscription activation
  • user billing page
  • admin payment log
  • daily renewal reminder job
  • simple support view

Do not include in v1:

  • coupons
  • tax engine
  • affiliate payouts
  • complex usage-based billing
  • multi-currency pricing rules
  • custom enterprise invoicing
  • full accounting exports
  • multi-provider abstraction

Those can come later.


Advanced version

Once the MVP works, you can add:

  • white-label checkout mode
  • static address balance top-ups
  • usage-based credits
  • payment history backfill
  • admin reconciliation queue
  • team/workspace billing
  • plan upgrades and downgrades
  • prorated upgrade credits
  • renewal reminder emails
  • Slack/Telegram/Discord notifications
  • API usage limit enforcement
  • hosted customer billing portal
  • export CSV for finance
  • multi-tenant merchant support
  • plugin for popular SaaS boilerplates

This is how the project grows from “integration” to “product.”


Revenue models for developers

There are several ways to monetize this module.

1. Paid boilerplate

Sell a complete Next.js, Laravel, or Django crypto billing starter.

Good for indie hackers and SaaS builders.

2. Open-source core + paid setup

Release the core module publicly, then charge for implementation, customization, support, and hosted admin tools.

Good if you want developer adoption.

3. Productized implementation service

Offer a fixed-scope package:

Crypto billing module for your SaaS:
- plan setup
- OxaPay invoice integration
- webhook verification
- subscription activation
- billing page
- admin payment log
- renewal reminder flow
Enter fullscreen mode Exit fullscreen mode

Good for freelance developers and small agencies.

4. Hosted billing connector

Build a small SaaS that sits between OxaPay and client SaaS apps.

Client apps call your API to create checkout sessions and receive subscription state through your SDK or webhooks.

This is harder, but more scalable.

5. Framework-specific premium package

Sell a polished package for one ecosystem.

For example:

  • Laravel crypto billing package
  • Next.js crypto SaaS billing kit
  • Django crypto subscription app
  • Node.js crypto billing module

Niche specificity makes the offer easier to understand.


Pricing examples

These numbers are not guaranteed revenue.

They are practical pricing structures developers can test.

Freelance implementation

Basic setup: $500–$1,500
Includes invoice creation, webhook handling, and plan activation.
Enter fullscreen mode Exit fullscreen mode

Advanced SaaS billing module

Custom build: $2,000–$8,000+
Includes admin dashboard, renewal logic, reconciliation, and white-label checkout.
Enter fullscreen mode Exit fullscreen mode

Paid boilerplate

Template license: $49–$299
Higher if it includes dashboard, docs, and production deployment guide.
Enter fullscreen mode Exit fullscreen mode

Monthly support

Maintenance: $100–$1,000/month
Depends on merchant volume, SLA, and support responsibility.
Enter fullscreen mode Exit fullscreen mode

Hosted module

SaaS pricing: $29–$299/month per merchant/app
Depends on usage, number of payment sessions, seats, and support tier.
Enter fullscreen mode Exit fullscreen mode

The best pricing depends on positioning.

A generic “crypto payment script” sells cheaply.

A framework-specific billing module that saves weeks of engineering can command much more.


Technical risks

A serious article needs to be honest about risks.

Webhook security

Invalid or unverified webhooks must never activate access.

Always validate HMAC against the raw body.

Duplicate events

Webhook systems may retry delivery.

Use idempotency keys and database locks.

Status confusion

Do not treat every payment status as successful.

Define exactly which provider status activates access.

Subscription edge cases

Handle renewal timing, grace periods, late payments, upgrade/downgrade behavior, and canceled subscriptions.

Support burden

Customers may pay late, pay on the wrong network, underpay, or close the checkout page.

Your module needs support visibility.

Compliance and accounting

You are building software infrastructure, not legal or tax advice.

Let merchants handle their own accounting, tax, jurisdiction, and compliance obligations.

Key management

Store API keys in environment variables or encrypted secret storage.

Do not expose merchant keys to frontend code.

Static address misuse

Static addresses are useful for balance top-ups, but they require careful tracking and should not be casually mixed with invoice-based subscriptions.


Testing checklist

Before selling this module, test at least these cases.

Invoice creation

  • valid plan creates invoice
  • inactive plan fails
  • missing user fails
  • provider error is handled
  • local session is not lost if provider call fails

Webhook handling

  • valid HMAC accepted
  • invalid HMAC rejected
  • duplicate webhook does not double-activate subscription
  • malformed JSON rejected safely
  • unknown track ID goes to reconciliation queue
  • paid status activates subscription
  • expired status does not activate subscription

Subscription logic

  • first payment creates active subscription
  • renewal payment extends current period
  • late renewal restores suspended account
  • expired invoice does not cancel existing active period early
  • plan upgrade changes plan correctly
  • grace period starts after expiry
  • suspended account blocks premium features

Backfill

  • Payment History sync finds missed paid payment
  • mismatch creates reconciliation task
  • already processed payment is ignored

UI

  • user sees pending payment
  • user sees confirming state
  • user sees active plan
  • user sees renewal date
  • admin can inspect payment session
  • support can search by track ID or order ID

Testing these cases is part of the product.

It is also part of what customers are paying you for.


21-day build plan

Here is a realistic build plan for an MVP.

Days 1–3: Product scope

  • choose one framework
  • define buyer persona
  • define plan model
  • write integration requirements
  • choose hosted invoice or white-label checkout for v1

Days 4–6: Database and billing state

  • create plan schema
  • create subscription schema
  • create checkout session schema
  • create webhook event schema
  • define state transitions

Days 7–9: Invoice flow

  • implement OxaPay invoice creation
  • store track ID and payment URL
  • build billing page
  • redirect user to payment
  • handle return URL

Days 10–12: Webhook flow

  • implement raw body parsing
  • validate HMAC
  • store webhook events
  • update session status
  • activate subscription on paid status

Days 13–15: Renewal logic

  • build renewal reminder job
  • create renewal invoices
  • handle grace period
  • suspend expired accounts

Days 16–18: Admin and support tools

  • build admin payment log
  • build subscription overview
  • add search by user/order/track ID
  • add reconciliation queue

Days 19–21: Hardening and packaging

  • add tests
  • write installation guide
  • document environment variables
  • create demo app
  • write deployment guide
  • define pricing package

After 21 days, you should not have a perfect billing company.

You should have a focused module that solves one painful problem well.


Example positioning for the product

Here is a clear offer:

A crypto billing module for SaaS apps that need invoice-based crypto payments, webhook-verified plan activation, renewal reminders, grace periods, and admin payment visibility.
Enter fullscreen mode Exit fullscreen mode

A more technical version:

Drop-in crypto subscription infrastructure for Laravel, Node.js, Django, and Next.js SaaS apps, powered by OxaPay invoices, webhooks, payment history, and subscription-state logic.
Enter fullscreen mode Exit fullscreen mode

A service version:

I help SaaS founders add crypto payments without rebuilding billing logic. The setup includes payment invoices, webhook verification, subscription activation, renewal reminders, and a support-ready admin view.
Enter fullscreen mode Exit fullscreen mode

This is much stronger than “I can accept crypto in your SaaS.”


What makes this different from a normal checkout integration?

A normal integration ends at payment.

A SaaS payment module continues after payment.

It knows:

  • who paid
  • what plan they bought
  • which workspace should be upgraded
  • when the billing period ends
  • whether the payment has been verified
  • whether the webhook has already been processed
  • whether the subscription should be active, past due, or suspended
  • whether support needs to investigate anything

That is the difference between integration work and productized infrastructure.


Designing the module API

If this becomes a real developer product, the public API matters.

A SaaS builder should not need to know every OxaPay endpoint to use your module.

They should call simple billing methods.

For example, a Node.js version could expose this interface:

type CreateCheckoutInput = {
  userId: string;
  workspaceId?: string;
  planId: string;
  successUrl: string;
  cancelUrl: string;
};

type CryptoBillingModule = {
  createCheckout(input: CreateCheckoutInput): Promise<{
    sessionId: string;
    paymentUrl: string;
    providerTrackId: string;
  }>;

  handleWebhook(rawBody: Buffer, headers: Record<string, string>): Promise<void>;

  getSubscription(input: {
    userId: string;
    workspaceId?: string;
  }): Promise<{
    status: string;
    planId: string;
    currentPeriodEnd: Date | null;
  }>;

  requireFeature(input: {
    userId: string;
    workspaceId?: string;
    feature: string;
  }): Promise<boolean>;
};
Enter fullscreen mode Exit fullscreen mode

This is the layer customers actually buy.

OxaPay remains behind the module.

The SaaS app uses your clean billing interface.

That makes the product easier to adopt, document, test, and sell.


Feature gating inside the SaaS app

A payment module is not complete until the SaaS app can enforce access.

Plan activation only matters if the product uses that state to unlock or restrict features.

Here is a simple middleware-style example:

export function requireActiveSubscription(requiredFeature) {
  return async function middleware(req, res, next) {
    const user = req.user;

    const subscription = await cryptoBilling.getSubscription({
      userId: user.id,
      workspaceId: user.workspace_id
    });

    if (!subscription || subscription.status !== "active") {
      return res.status(402).json({
        error: "subscription_required",
        message: "Please renew your plan to access this feature."
      });
    }

    const allowed = await cryptoBilling.requireFeature({
      userId: user.id,
      workspaceId: user.workspace_id,
      feature: requiredFeature
    });

    if (!allowed) {
      return res.status(403).json({
        error: "feature_not_in_plan",
        message: "Your current plan does not include this feature."
      });
    }

    return next();
  };
}
Enter fullscreen mode Exit fullscreen mode

This is where the module becomes useful to SaaS teams.

It does not merely record payments.

It controls product access.


Example: AI SaaS credit model

Suppose an AI SaaS product sells both monthly plans and usage credits.

The module could support two flows.

Subscription flow

User buys Pro Monthly
  ↓
OxaPay invoice created
  ↓
Webhook paid event received
  ↓
Subscription active until next billing date
  ↓
Feature gates unlock Pro features
Enter fullscreen mode Exit fullscreen mode

Credit top-up flow

User buys 1,000 AI credits
  ↓
OxaPay invoice or static address payment created
  ↓
Webhook paid event received
  ↓
Internal credit ledger increases by 1,000
  ↓
Each generation consumes credits
Enter fullscreen mode Exit fullscreen mode

The database should keep these separate.

A subscription controls access.

A credit ledger controls usage.

This separation helps avoid confusing billing bugs later.


Example: API SaaS plan enforcement

For an API-as-a-service product, the module can control rate limits.

Example plan definitions:

[
  {
    "id": "starter_monthly",
    "name": "Starter",
    "monthly_api_calls": 10000,
    "amount": 19
  },
  {
    "id": "pro_monthly",
    "name": "Pro",
    "monthly_api_calls": 100000,
    "amount": 79
  }
]
Enter fullscreen mode Exit fullscreen mode

When payment is confirmed, the module updates the subscription.

The API gateway checks subscription status and plan limits before serving requests.

Incoming API request
  ↓
Identify workspace
  ↓
Check active subscription
  ↓
Check plan quota
  ↓
Allow or reject request
Enter fullscreen mode Exit fullscreen mode

Now the module is not just a checkout system.

It becomes part of revenue enforcement.


Multi-tenant considerations

Many SaaS products are workspace-based.

That means one user may belong to multiple workspaces, and each workspace may have a separate subscription.

Your module should decide early whether billing belongs to:

  • individual users
  • workspaces
  • organizations
  • teams
  • projects
  • API keys

Do not hard-code everything to user_id.

A better design is to let the implementer choose the billing owner.

type BillingOwner = {
  type: "user" | "workspace" | "organization";
  id: string;
};
Enter fullscreen mode Exit fullscreen mode

This makes the module usable across more SaaS products.

It also reduces painful refactoring later.


Installation experience

If you sell this as a developer product, installation experience matters as much as the code.

A good setup guide should include:

1. Install package
2. Add environment variables
3. Run migrations
4. Define plans
5. Register webhook route
6. Configure OxaPay callback URL
7. Add billing page component
8. Add feature-gating middleware
9. Test invoice creation
10. Test webhook activation
Enter fullscreen mode Exit fullscreen mode

For a Laravel package, that might look like:

composer require yourname/crypto-saas-billing
php artisan vendor:publish --tag=crypto-billing-config
php artisan migrate
Enter fullscreen mode Exit fullscreen mode

For a Node.js package:

npm install crypto-saas-billing
npx crypto-billing init
npx crypto-billing migrate
Enter fullscreen mode Exit fullscreen mode

For a Next.js starter:

npx create-next-app my-saas --example your-crypto-billing-template
Enter fullscreen mode Exit fullscreen mode

The easier the setup, the more likely developers are to adopt it.


Documentation pages your product should include

A serious module needs documentation, not just code.

Minimum docs:

  • Quick start
  • Environment variables
  • Plan configuration
  • Creating checkout sessions
  • Handling webhooks
  • Subscription states
  • Renewal behavior
  • Feature gating
  • Admin dashboard
  • Reconciliation jobs
  • Testing webhooks locally
  • Deployment checklist
  • Troubleshooting guide

The troubleshooting guide is especially important.

Real users will ask:

  • Why is my subscription still pending?
  • Why did the invoice expire?
  • Why did the webhook fail?
  • Why did the user pay but access was not activated?
  • How do I manually restore access?
  • How do I find the provider track ID?

Good documentation reduces support cost.

It also makes the module feel like a real product.


When not to build this

This is not the right product if your target users need automatic card-style recurring billing, complex tax handling, enterprise invoicing, or native fiat payment methods.

It is also not ideal for SaaS apps that have no reason to accept crypto payments.

The best use cases are products where crypto payment preference is real, international access matters, or the customer base already understands wallets and stablecoins.

Strong niches include:

  • AI tools
  • developer APIs
  • hosting panels
  • proxy and VPN panels
  • software licenses
  • creator tools
  • Telegram/Discord SaaS
  • digital service platforms
  • global B2B micro-SaaS

A narrow module for a real niche is better than a generic module for everyone.


Developer takeaway

A crypto payment module for SaaS apps is not just a wrapper around a payment API.

It is a billing-state system.

OxaPay can provide the payment primitives: invoices, white-label payment details, static addresses, webhooks, payment information, payment history, and SDKs.

Your module provides the SaaS logic: plans, checkout sessions, subscription states, renewals, grace periods, admin screens, support workflows, and reconciliation.

That combination is valuable because SaaS builders do not want to rebuild payment operations from scratch.

If you are a developer looking for a real product opportunity around crypto payments, this is one of the strongest ones:

Build the module that turns crypto payments into working SaaS subscriptions.


References

Top comments (0)