A customer pays for a SaaS plan.
The payment provider reports paid, but the workspace still has Free-plan limits.
Another customer pays for a renewal. A duplicate webhook arrives and the subscription is extended twice.
A third customer creates two renewal invoices, pays both, and receives an access period the billing system was not designed to handle.
The payment integration works.
The billing model does not.
A SaaS product does not only need a crypto payment button. It needs a module that can translate verified payments into controlled subscription terms and product entitlements.
That module must know:
- who owns the subscription
- which plan was purchased
- what the payment is for
- when the access period begins and ends
- whether the payment has been verified
- whether the same payment was already applied
- which features should be available
- what happens when renewal does not occur
- how support can investigate mismatches
- how missed events are recovered
This article uses OxaPay as the payment infrastructure reference, but the billing architecture is provider-agnostic.
This article is part of 10 Crypto Payment Products Developers Can Build for Merchants.
The module owns billing state
A payment provider can tell your application:
A payment session was created.
Payment activity was detected.
The payment reached paid status.
The payment expired.
The payment was refunded.
Your SaaS module must decide what those events mean for the product.
For example:
Payment paid
-> Create one subscription term
-> Activate the purchased plan
-> Apply the correct entitlements
-> Record the reason for the access change
The payment provider should not become the source of truth for:
- SaaS plans
- workspace ownership
- subscription terms
- feature limits
- grace periods
- access policies
- upgrade rules
- internal credit balances
OxaPay provides payment infrastructure.
Your module provides billing logic.
This is not card-style recurring billing
A normal card subscription may allow the merchant to charge a saved payment method automatically at the start of each billing period.
An invoice-based crypto flow usually requires new customer action.
A practical renewal lifecycle is:
Subscription approaching expiry
-> Create or offer renewal invoice
-> Notify customer
-> Customer completes payment
-> Verify paid status
-> Grant the next subscription term
If the customer does not renew:
Active term ends
-> Grace period begins
-> Account becomes past due
-> Restricted or suspended state begins
Do not describe this as automatic recurring billing unless the product actually has an authorized mechanism for collecting future payments automatically.
A more accurate product promise is:
Invoice-based crypto billing with subscription activation, renewal reminders, grace periods, and access enforcement.
Separate the five core entities
A safe SaaS billing module should not compress everything into one subscription row.
Keep these concepts separate.
Plan
The commercial offer selected by the customer.
Pro Monthly
$29
One-month term
Five team members
100,000 API calls
Checkout session
One attempt to pay for a plan, renewal, upgrade, or credit package.
A subscription can have many checkout sessions.
Payment event
Immutable evidence received from the provider or recovered through an API query.
A payment event is not the same as the current payment state.
Subscription term
One access period granted because of one accepted payment or an authorized administrative action.
August 1 to September 1
Granted by checkout session cs_1842
Entitlement
The product access created by the active plan.
Examples:
projects.max = 50
team_members.max = 5
api_calls.monthly = 100000
feature.analytics = true
This separation prevents several common billing bugs.
Payment state is not subscription state
The provider and the SaaS application operate different state machines.
Payment session states
created
waiting
confirming
paid
underpaid
expired
refunded
review_required
Subscription states
pending
active
grace_period
suspended
canceled
Entitlement states
scheduled
active
expired
revoked
A payment may expire while the existing subscription remains active.
Renewal invoice: expired
Subscription: active until August 31
Entitlements: active
An expired renewal invoice should not immediately cancel access that was already paid for.
Another valid combination is:
Payment: paid
Subscription term: not granted
Entitlements: unchanged
Operational case: open
This means the payment succeeded, but internal billing activation failed.
That incident must remain visible.
Decide who owns the subscription
Do not assume every SaaS subscription belongs directly to a user.
Billing may belong to:
- an individual user
- a workspace
- an organization
- a team
- a project
- an API account
Create a billing account abstraction.
type BillingOwnerType =
| "user"
| "workspace"
| "organization";
type BillingAccount = {
id: string;
ownerType: BillingOwnerType;
ownerId: string;
};
The checkout session should reference the billing account, not only the currently authenticated user.
This prevents problems when:
- several users belong to one paid workspace
- the workspace owner changes
- an administrator completes payment for another team
- one user belongs to several organizations
- invoices are created through an internal admin flow
The payment options provided by OxaPay
OxaPay exposes several primitives that fit different SaaS billing models.
Hosted invoice
Use Generate Invoice when the application can redirect the customer to an OxaPay payment page.
This is usually the best MVP option because it reduces checkout UI complexity.
White-label payment
Use Generate White Label when the SaaS application needs to render the payment details inside its own billing interface.
The application becomes responsible for displaying the amount, currency, network, payment address, QR code, expiration time, and current payment state clearly.
Static address
Use a Static Address for account funding or repeated deposits.
Examples:
- API usage credits
- AI generation credits
- hosting balance
- proxy bandwidth balance
- internal prepaid wallet
Do not use one static address as a shortcut for ordinary subscription invoices unless the application has reliable rules for connecting deposits to billing decisions.
Payment Information
Use Payment Information to retrieve the latest provider state for a specific track_id.
This is useful before sensitive activation and during support investigation.
Payment History
Use Payment History for recovery, reporting, and provider-to-local reconciliation.
Webhooks are the real-time path.
Payment Information and Payment History are recovery paths.
Recommended architecture
+----------------------+
| SaaS Billing UI |
+----------+-----------+
|
| Select plan
v
+----------------------+
| Billing Module API |
+----------+-----------+
|
| Create invoice
v
+----------------------+
| OxaPay |
+----------+-----------+
|
| Payment webhook
v
+----------------------+
| Webhook Receiver |
+----------+-----------+
|
| Verify and persist
v
+----------------------+
| Payment Event Store |
+----------+-----------+
|
v
+----------------------+
| Transactional Outbox |
+----------+-----------+
|
v
+----------------------+
| Billing Worker |
+----------+-----------+
|
| Verify payment
v
+----------------------+
| Subscription Service |
+----------+-----------+
|
v
+----------------------+
| Term + Entitlements |
+----------+-----------+
|
v
User UI / Admin / Support / Reconciliation
The Webhook Receiver should not activate the subscription directly.
Its responsibilities are limited:
- Identify the merchant integration.
- Preserve the raw request body.
- validate the HMAC signature.
- Store the payment event.
- Create an outbox job.
- Return the expected success response.
Subscription activation belongs in a background worker.
A practical data model
CREATE TABLE billing_accounts (
id UUID PRIMARY KEY,
owner_type TEXT NOT NULL,
owner_id TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (owner_type, owner_id)
);
CREATE TABLE saas_plans (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
price NUMERIC(20, 8) NOT NULL,
currency TEXT NOT NULL,
billing_months INTEGER NOT NULL,
entitlements JSONB NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE subscriptions (
id UUID PRIMARY KEY,
billing_account_id UUID NOT NULL
REFERENCES billing_accounts(id),
current_plan_id TEXT REFERENCES saas_plans(id),
status TEXT NOT NULL DEFAULT 'pending',
current_term_start TIMESTAMP,
current_term_end TIMESTAMP,
grace_until TIMESTAMP,
cancel_at_term_end BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (billing_account_id)
);
CREATE TABLE checkout_sessions (
id UUID PRIMARY KEY,
billing_account_id UUID NOT NULL
REFERENCES billing_accounts(id),
subscription_id UUID REFERENCES subscriptions(id),
plan_id TEXT NOT NULL REFERENCES saas_plans(id),
purpose TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'oxapay',
provider_order_id TEXT NOT NULL,
provider_track_id TEXT,
provider_status TEXT,
internal_status TEXT NOT NULL DEFAULT 'created',
amount NUMERIC(20, 8) NOT NULL,
currency TEXT NOT NULL,
payment_url TEXT,
expires_at TIMESTAMP,
paid_at TIMESTAMP,
created_by TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (provider, provider_track_id),
UNIQUE (provider_order_id)
);
CREATE TABLE payment_events (
id UUID PRIMARY KEY,
billing_account_id UUID NOT NULL
REFERENCES billing_accounts(id),
checkout_session_id UUID REFERENCES checkout_sessions(id),
provider TEXT NOT NULL,
payload_hash TEXT NOT NULL,
provider_track_id TEXT,
provider_status TEXT,
raw_payload JSONB NOT NULL,
signature_valid BOOLEAN NOT NULL,
source TEXT NOT NULL,
received_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (billing_account_id, payload_hash)
);
CREATE TABLE subscription_terms (
id UUID PRIMARY KEY,
subscription_id UUID NOT NULL
REFERENCES subscriptions(id),
checkout_session_id UUID REFERENCES checkout_sessions(id),
plan_id TEXT NOT NULL REFERENCES saas_plans(id),
starts_at TIMESTAMP NOT NULL,
ends_at TIMESTAMP NOT NULL,
grant_type TEXT NOT NULL,
granted_by TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (checkout_session_id)
);
CREATE TABLE entitlement_grants (
id UUID PRIMARY KEY,
subscription_term_id UUID NOT NULL
REFERENCES subscription_terms(id),
entitlement_key TEXT NOT NULL,
entitlement_value JSONB NOT NULL,
starts_at TIMESTAMP NOT NULL,
ends_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (subscription_term_id, entitlement_key)
);
CREATE TABLE outbox_jobs (
id UUID PRIMARY KEY,
topic TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
available_at TIMESTAMP NOT NULL DEFAULT NOW(),
published_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE billing_cases (
id UUID PRIMARY KEY,
billing_account_id UUID NOT NULL
REFERENCES billing_accounts(id),
checkout_session_id UUID REFERENCES checkout_sessions(id),
subscription_id UUID REFERENCES subscriptions(id),
case_type TEXT NOT NULL,
severity TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
summary TEXT NOT NULL,
recommended_action TEXT,
resolution_note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMP
);
The most important constraint is:
UNIQUE (checkout_session_id)
on subscription_terms.
One accepted checkout session can grant only one subscription term.
A duplicate webhook cannot extend the subscription twice.
Create the local checkout first
Create a local checkout session before calling the provider.
This gives the module an internal identifier even if the provider request times out or returns an error.
import crypto from "node:crypto";
const OXAPAY_API = "https://api.oxapay.com/v1";
export async function createPlanCheckout({
billingAccountId,
planId,
purpose = "new_subscription",
customerEmail,
}) {
const plan = await db.saasPlan.findUnique({
where: { id: planId },
});
if (!plan || !plan.active) {
throw new Error("Plan is not available");
}
const checkoutId = crypto.randomUUID();
const providerOrderId = `saas_${checkoutId}`;
const checkout = await db.checkoutSession.create({
data: {
id: checkoutId,
billingAccountId,
planId: plan.id,
purpose,
provider: "oxapay",
providerOrderId,
internalStatus: "created",
amount: plan.price,
currency: plan.currency,
},
});
try {
const merchantApiKey = await loadMerchantApiKey();
const response = await fetch(
`${OXAPAY_API}/payment/invoice`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
merchant_api_key: merchantApiKey,
},
body: JSON.stringify({
amount: plan.price,
currency: plan.currency,
order_id: providerOrderId,
email: customerEmail,
description: `${plan.name} SaaS plan`,
callback_url:
`${process.env.APP_URL}/webhooks/oxapay/payment`,
return_url:
`${process.env.APP_URL}/billing/checkouts/${checkoutId}`,
lifetime: 60,
sandbox: process.env.NODE_ENV !== "production",
}),
},
);
const payload = await response.json();
if (!response.ok) {
throw new Error(
payload?.error?.message ??
`Invoice request failed with ${response.status}`,
);
}
const payment = payload.data;
return db.checkoutSession.update({
where: { id: checkout.id },
data: {
providerTrackId: String(payment.track_id),
providerStatus: "new",
internalStatus: "invoice_created",
paymentUrl: payment.payment_url,
expiresAt: payment.expired_at
? new Date(Number(payment.expired_at) * 1000)
: null,
},
});
} catch (error) {
await db.checkoutSession.update({
where: { id: checkout.id },
data: {
internalStatus: "creation_failed",
},
});
throw error;
}
}
Do not allow the frontend to submit its own price.
The backend should load the authoritative plan price from the plan catalog.
Validate OxaPay callbacks
OxaPay sends the payment signature in the HMAC header. The value is calculated using SHA-512 over the raw request body.
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post(
"/webhooks/oxapay/payment",
express.raw({ type: "application/json" }),
async (req, res) => {
const rawBody = req.body;
const receivedHmac = req.get("HMAC");
const merchantApiKey = await loadMerchantApiKey();
const expectedHmac = crypto
.createHmac("sha512", merchantApiKey)
.update(rawBody)
.digest("hex");
if (!safeEqualSha512(receivedHmac, expectedHmac)) {
await recordRejectedWebhook({
reason: "invalid_hmac",
});
return res.status(401).send("invalid signature");
}
let payload;
try {
payload = JSON.parse(rawBody.toString("utf8"));
} catch {
return res.status(400).send("invalid json");
}
const payloadHash = crypto
.createHash("sha256")
.update(rawBody)
.digest("hex");
try {
await persistEventAndOutbox({
payload,
payloadHash,
});
return res.status(200).send("ok");
} catch (error) {
console.error("Webhook persistence failed", error);
return res.status(500).send("failed");
}
},
);
function safeEqualSha512(received, expected) {
const sha512Hex = /^[a-f0-9]{128}$/i;
if (
!received ||
!expected ||
!sha512Hex.test(received) ||
!sha512Hex.test(expected)
) {
return false;
}
return crypto.timingSafeEqual(
Buffer.from(received, "hex"),
Buffer.from(expected, "hex"),
);
}
The payment event and outbox job should be inserted in one database transaction.
async function persistEventAndOutbox({
payload,
payloadHash,
}) {
const trackId = String(payload.track_id ?? "");
if (!trackId) {
throw new Error("Webhook is missing track_id");
}
const checkout = await db.checkoutSession.findUnique({
where: {
provider_providerTrackId: {
provider: "oxapay",
providerTrackId: trackId,
},
},
});
if (!checkout) {
await createBillingCase({
caseType: "unknown_provider_payment",
severity: "high",
summary: `No checkout session matches track_id ${trackId}`,
evidence: payload,
});
return;
}
await db.$transaction(async (tx) => {
const event = await tx.paymentEvent.upsert({
where: {
billingAccountId_payloadHash: {
billingAccountId: checkout.billingAccountId,
payloadHash,
},
},
create: {
billingAccountId: checkout.billingAccountId,
checkoutSessionId: checkout.id,
provider: "oxapay",
payloadHash,
providerTrackId: trackId,
providerStatus: normalizeStatus(payload.status),
rawPayload: payload,
signatureValid: true,
source: "webhook",
},
update: {},
});
await tx.outboxJob.create({
data: {
topic: "billing.payment_event_received",
payload: {
paymentEventId: event.id,
},
},
});
});
}
function normalizeStatus(status) {
return String(status ?? "").trim().toLowerCase();
}
The outbox dispatcher can publish the event to a billing queue.
The Webhook Receiver remains fast and does not perform subscription activation.
Map provider status into payment state
OxaPay payment statuses may include states such as:
newwaitingpayingpaidmanual_acceptunderpaidexpiredrefundingrefunded
Map them into your own model.
const STATUS_MAP = {
new: "created",
waiting: "waiting",
paying: "confirming",
paid: "paid",
manual_accept: "manually_accepted",
underpaid: "underpaid",
expired: "expired",
refunding: "refund_in_progress",
refunded: "refunded",
};
function mapPaymentStatus(providerStatus) {
return STATUS_MAP[providerStatus] ?? "unknown";
}
Do not activate a subscription on paying.
The user-facing UI may show:
Payment detected. Waiting for completion.
The term should normally be granted after paid.
A manually accepted payment should follow a separate merchant policy and preserve an audit record.
Verify the payment before granting access
A valid webhook proves that the callback came from the payment provider.
Before granting a subscription term, retrieve the latest Payment Information and verify the business facts.
async function fetchOxaPayPayment(trackId) {
const merchantApiKey = await loadMerchantApiKey();
const response = await fetch(
`${OXAPAY_API}/payment/${encodeURIComponent(trackId)}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
merchant_api_key: merchantApiKey,
},
},
);
const payload = await response.json();
if (!response.ok) {
throw new Error(
payload?.error?.message ??
`Payment lookup failed with ${response.status}`,
);
}
return payload.data;
}
function verifyPaymentForCheckout({
payment,
checkout,
}) {
if (
normalizeStatus(payment.status) !== "paid"
) {
throw new PermanentBillingError(
"Payment is not in the paid state",
);
}
if (
String(payment.order_id) !==
String(checkout.providerOrderId)
) {
throw new PermanentBillingError(
"Payment order_id does not match checkout",
);
}
if (
decimal(payment.amount).lessThan(
decimal(checkout.amount),
)
) {
throw new PermanentBillingError(
"Paid amount does not satisfy checkout",
);
}
}
Use decimal arithmetic for financial comparisons.
Amount handling may also require a merchant-defined currency policy.
Grant one subscription term per payment
The subscription term is the durable business result of the payment.
import { addMonths } from "date-fns";
export async function applyPaidCheckout(
checkoutId,
) {
return db.$transaction(async (tx) => {
const checkout =
await tx.checkoutSession.findUnique({
where: { id: checkoutId },
});
if (!checkout) {
throw new PermanentBillingError(
"Checkout session not found",
);
}
const existingTerm =
await tx.subscriptionTerm.findUnique({
where: {
checkoutSessionId: checkout.id,
},
});
if (existingTerm) {
return existingTerm;
}
const plan = await tx.saasPlan.findUnique({
where: { id: checkout.planId },
});
if (!plan || !plan.active) {
throw new PermanentBillingError(
"Purchased plan is unavailable",
);
}
let subscription =
await tx.subscription.findUnique({
where: {
billingAccountId:
checkout.billingAccountId,
},
});
const now = new Date();
if (!subscription) {
subscription = await tx.subscription.create({
data: {
billingAccountId:
checkout.billingAccountId,
currentPlanId: plan.id,
status: "pending",
},
});
}
const startsAt =
subscription.currentTermEnd &&
subscription.currentTermEnd > now
? subscription.currentTermEnd
: now;
const endsAt = addMonths(
startsAt,
plan.billingMonths,
);
const term =
await tx.subscriptionTerm.create({
data: {
subscriptionId: subscription.id,
checkoutSessionId: checkout.id,
planId: plan.id,
startsAt,
endsAt,
grantType: checkout.purpose,
grantedBy: "verified_payment",
},
});
for (const [key, value] of Object.entries(
plan.entitlements,
)) {
await tx.entitlementGrant.create({
data: {
subscriptionTermId: term.id,
entitlementKey: key,
entitlementValue: value,
startsAt,
endsAt,
},
});
}
await tx.subscription.update({
where: { id: subscription.id },
data: {
currentPlanId: plan.id,
status: "active",
currentTermStart: startsAt,
currentTermEnd: endsAt,
graceUntil: null,
},
});
await tx.checkoutSession.update({
where: { id: checkout.id },
data: {
providerStatus: "paid",
internalStatus: "applied",
paidAt: now,
},
});
return term;
});
}
The unique constraint on checkout_session_id makes this operation idempotent.
If the same paid transition is processed again, the existing term is returned.
Do not hide plan-change policy
Renewal is straightforward when the customer buys the same plan for another term.
Upgrades and downgrades need explicit rules.
Questions include:
- Does an upgrade begin immediately?
- Is unused time converted into credit?
- Does the new term start after the current term?
- Are remaining quotas reset?
- Does a downgrade begin immediately or at term end?
- Can a workspace have overlapping entitlement grants?
Do not bury these decisions inside a generic activation function.
A safe first version can support:
New subscription
Renewal of the same plan
Then add plan changes as explicit operations.
Scheduled downgrade
Current plan remains active
-> Downgrade stored as pending
-> Lower plan begins at current term end
Immediate upgrade
Upgrade invoice paid
-> Existing term closed or adjusted
-> Upgrade policy applied
-> New entitlement set activated
-> Adjustment recorded in audit history
There is no universally correct upgrade model.
The module should make the policy configurable and visible.
Renewal should create another checkout
A renewal job should not extend access on its own.
It should identify subscriptions approaching expiration and create or offer a renewal payment session.
export async function prepareRenewals() {
const reminderBoundary = addDays(
new Date(),
5,
);
const subscriptions =
await db.subscription.findMany({
where: {
status: "active",
cancelAtTermEnd: false,
currentTermEnd: {
lte: reminderBoundary,
},
},
});
for (const subscription of subscriptions) {
const existingCheckout =
await db.checkoutSession.findFirst({
where: {
subscriptionId: subscription.id,
purpose: "renewal",
internalStatus: {
in: [
"created",
"invoice_created",
"waiting",
"confirming",
],
},
},
});
if (existingCheckout) {
continue;
}
await createRenewalCheckout(subscription);
}
}
The renewal lifecycle can be:
Five days before expiry:
Create or offer renewal checkout
Three days before expiry:
Send reminder
At term end:
Enter grace period
At grace end:
Suspend protected features
Do not create a new invoice every time the scheduled job runs.
Use one open renewal checkout per subscription and renewal window.
Model grace periods explicitly
A grace period is not an extension granted by payment.
It is a merchant policy.
export async function updateExpiredTerms() {
const now = new Date();
const endedSubscriptions =
await db.subscription.findMany({
where: {
status: "active",
currentTermEnd: {
lte: now,
},
},
});
for (const subscription of endedSubscriptions) {
const graceUntil = addDays(now, 3);
await db.subscription.update({
where: { id: subscription.id },
data: {
status: "grace_period",
graceUntil,
},
});
}
const expiredGracePeriods =
await db.subscription.findMany({
where: {
status: "grace_period",
graceUntil: {
lte: now,
},
},
});
for (const subscription of expiredGracePeriods) {
await db.subscription.update({
where: { id: subscription.id },
data: {
status: "suspended",
},
});
}
}
Decide which features remain available during grace.
For example:
Read existing data: allowed
Create new projects: blocked
Use paid API quota: limited
Export data: allowed
Invite team members: blocked
A complete account lock may not be the best product experience.
Enforce entitlements inside the product
Payment integration is incomplete until the application actually enforces the purchased plan.
export function requireEntitlement(
entitlementKey,
) {
return async function entitlementMiddleware(
req,
res,
next,
) {
const billingAccountId =
req.billingAccount.id;
const subscription =
await billing.getSubscription(
billingAccountId,
);
if (
!subscription ||
!["active", "grace_period"].includes(
subscription.status,
)
) {
return res.status(402).json({
error: "active_subscription_required",
});
}
const entitlement =
await billing.getActiveEntitlement({
subscriptionId: subscription.id,
key: entitlementKey,
at: new Date(),
});
if (!entitlement) {
return res.status(403).json({
error: "entitlement_not_available",
});
}
req.entitlement = entitlement;
return next();
};
}
For numeric limits, the application can compare usage against the active entitlement.
team_members.max = 5
api_calls.monthly = 100000
projects.max = 50
The module should expose billing decisions through a clean interface.
The rest of the SaaS application should not query raw payment tables.
Keep credits separate from subscriptions
Some SaaS products sell both time-based access and usage credits.
For example:
Pro Monthly subscription
+
10,000 additional AI credits
These need separate models.
Subscription
Controls time-based access and feature availability.
Credit ledger
Controls consumable units.
A credit system should use an append-only ledger:
+10,000 Purchased credit package
-25 Image generation
-100 Video generation
+500 Manual adjustment
Do not store only:
credit_balance = 10375
without recording how the balance changed.
Static addresses can support repeated account deposits, but every incoming transaction still needs:
- account ownership mapping
- unique transaction handling
- currency conversion policy
- credit calculation
- ledger entry
- audit evidence
- reversal policy
Start with invoice-based credit packages unless repeated deposits are a proven requirement.
Recover missed events
A production module should not assume every webhook is delivered and processed correctly.
Use Payment History periodically.
A recovery job can detect:
- provider payment is
paid, local checkout is not applied - provider payment expired, local checkout remains waiting
- provider payment exists without a local session
- checkout is paid, but no subscription term exists
- subscription term exists, but entitlements are missing
- several open renewal sessions exist for one subscription
A practical schedule:
Every 10 minutes:
- retrieve an overlapping recent payment window
- upsert provider records by track_id
- compare provider and local states
- create recovery events
- create cases for unsafe mismatches
Every night:
- compare paid checkouts with subscription terms
- compare active terms with entitlement grants
- report unresolved billing cases
Recovered events should be labeled:
source = payment_history_backfill
Do not present them as original webhook deliveries.
Create operational cases
Do not silently repair every inconsistency.
Useful case types include:
| Case | Meaning |
|---|---|
paid_not_applied |
Provider confirms payment, but no term was granted |
unknown_payment |
Provider payment has no matching checkout |
amount_mismatch |
Payment does not satisfy the checkout |
duplicate_renewal_payment |
More than one accepted renewal exists for the same intended period |
term_without_entitlements |
Subscription term exists, but access grants are incomplete |
refunded_but_active |
Payment was refunded while paid access remains active |
expired_with_customer_claim |
Checkout expired, but the customer reports sending funds |
invalid_webhook |
Callback failed HMAC validation |
Every case should contain:
- billing account
- checkout session
- provider
track_id - plan
- expected amount
- provider state
- local state
- evidence
- recommended action
- assigned owner
- resolution note
This is where the module connects to payment reconciliation and the Crypto Payment Support Desk.
Build user-facing billing states
The billing UI should explain what the customer needs to do.
No active subscription
Choose a plan to continue.
Waiting for payment
Your payment session is ready. Complete the payment before it expires.
Payment detected
We detected payment activity. Do not send another payment while it is being processed.
Payment confirmed, activation pending
Your payment is confirmed. We are activating your plan.
Active
Your Pro plan is active until September 1.
Grace period
Your paid term has ended. Renew before September 4 to avoid service suspension.
Suspended
Your subscription is suspended. Complete a renewal payment to restore paid access.
The customer should not need to interpret provider-specific statuses.
Build an admin billing workspace
A real SaaS operator needs more than a payment log.
Useful admin views include:
Billing account
- owner type and ID
- current plan
- subscription status
- current term
- grace deadline
- active entitlements
- scheduled plan change
Checkout sessions
- purpose
- plan
- expected amount
- provider
track_id - payment status
- expiration
- applied term
- payment source
Subscription timeline
August 1 Checkout created
August 1 Payment confirmed
August 1 Pro Monthly term granted
August 1 Entitlements activated
August 26 Renewal invoice created
September 1 Grace period started
September 2 Renewal payment confirmed
September 2 Next term granted
Needs attention
- paid but not activated
- unknown payments
- amount mismatches
- expired payment claims
- invalid callbacks
- refunded payments with active access
- entitlement projection failures
Safe actions
- refresh Payment Information
- send renewal reminder
- create replacement checkout
- retry billing activation
- assign case
- add internal note
- apply authorized manual grant
- suspend or restore access under policy
Every manual grant or override needs an audit record.
Define the module API
A SaaS developer should not need to understand every OxaPay endpoint.
Expose a billing-oriented interface.
type CreateCheckoutInput = {
billingAccountId: string;
planId: string;
purpose:
| "new_subscription"
| "renewal"
| "upgrade";
customerEmail?: string;
};
type BillingModule = {
createCheckout(
input: CreateCheckoutInput
): Promise<{
checkoutId: string;
paymentUrl: string;
providerTrackId: string;
expiresAt: Date | null;
}>;
getSubscription(
billingAccountId: string
): Promise<{
status: string;
planId: string | null;
currentTermEnd: Date | null;
graceUntil: Date | null;
}>;
getEntitlement(input: {
billingAccountId: string;
key: string;
}): Promise<unknown | null>;
createRenewal(
billingAccountId: string
): Promise<{
checkoutId: string;
paymentUrl: string;
}>;
refreshPayment(
checkoutId: string
): Promise<void>;
};
OxaPay remains behind this interface.
The SaaS application works with:
Checkout
Subscription
Term
Entitlement
Renewal
That is the product developers buy.
Package it for one framework first
Do not launch Laravel, Node.js, Django, and Next.js versions simultaneously.
Choose one ecosystem.
Laravel package
A useful package can provide:
- migrations
- OxaPay configuration
- billing-account contract
- webhook route
- queue jobs
- subscription and term models
- entitlement middleware
- Artisan renewal commands
- admin components
Possible developer interface:
$checkout = $workspace
->cryptoBilling()
->checkout('pro_monthly');
$workspace
->cryptoBilling()
->hasEntitlement('analytics');
Node.js module
A Node.js package can provide:
- OxaPay client
- webhook verifier
- event normalizer
- database adapter contract
- billing service
- renewal scheduler
- Express or Fastify middleware
- React billing components
Django app
A Django package can provide:
- models and migrations
- webhook view
- Celery jobs
- Django Admin integration
- subscription services
- entitlement decorators
Next.js starter
A Next.js product can include:
- Prisma models
- checkout API route
- Webhook Route Handler
- billing portal
- admin payment page
- cron endpoint
- entitlement helpers
- deployment guide
Framework specificity makes the product easier to install, document, and sell.
The MVP
The first version should solve one complete use case:
A workspace buys one monthly SaaS plan with crypto and receives the correct access exactly once.
Build:
- billing account model
- plan catalog
- subscription table
- checkout sessions
- hosted OxaPay invoices
- HMAC-validated webhooks
- event and outbox storage
- Payment Information verification
- idempotent subscription-term grants
- entitlement checks
- renewal reminder
- grace and suspension states
- user billing page
- admin payment timeline
- paid-but-not-activated case detection
- Payment History recovery
Do not include in the first version:
- prorated upgrades
- tax calculation
- coupons
- affiliate payouts
- automatic payouts
- several payment providers
- advanced usage metering
- full accounting
- visual billing workflow builders
A smaller reliable module is more valuable than a broad billing system with unclear state transitions.
Production safeguards
Before selling the module, implement:
- HMAC validation over the raw callback body
- timing-safe signature comparison
- encrypted Merchant API Key storage
- strict tenant isolation
- unique payment and term constraints
- asynchronous event processing
- transactional outbox delivery
- decimal financial arithmetic
- explicit payment and subscription states
- retry classification
- Payment Information verification
- Payment History recovery
- manual-action audit logs
- role-based admin permissions
- raw payload retention rules
- secret masking in logs
- alerting for paid-but-not-activated cases
Test at least:
Valid paid payment creates one term
Duplicate paid webhook does not create another term
Different duplicate payloads do not grant access twice
Confirming payment does not activate access
Expired renewal does not cancel the current active term
Late renewal restores access under policy
Unknown track_id creates a case
Paid payment with wrong order_id is rejected
Provider paid, local pending is recovered
Active term has the expected entitlements
Refund creates an operational review
These tests are part of the product, not optional engineering polish.
How to position the module
Weak positioning:
Add OxaPay payments to your SaaS.
Better positioning:
Add invoice-based crypto billing to your SaaS.
Stronger positioning:
A crypto billing module that connects verified OxaPay payments to subscription terms, workspace entitlements, renewals, grace periods, and support-ready billing records.
A framework-specific offer can be even clearer:
Crypto billing for Laravel SaaS applications, including hosted invoices, verified webhooks, subscription terms, entitlement middleware, renewal reminders, and admin payment visibility.
The buyer is not purchasing an API wrapper.
They are purchasing the billing behavior that sits behind the API.
What makes this a real product?
A normal checkout integration says:
Create invoice
-> Receive paid webhook
-> Set user.plan = pro
A production SaaS payment module says:
Create authoritative local checkout
-> Generate payment session
-> Validate callback
-> Preserve payment evidence
-> Verify provider state
-> Match billing account and plan
-> Grant one immutable subscription term
-> Activate term-scoped entitlements
-> Prevent duplicate activation
-> Prepare invoice-based renewal
-> Apply grace and suspension policy
-> Recover missed events
-> Surface unsafe mismatches
That is the difference between payment integration and billing infrastructure.
Final takeaway
A Crypto Payment Module for SaaS Apps is not a payment button and not a thin API wrapper.
It is a billing-state system.
OxaPay provides the payment primitives:
- hosted invoices
- white-label payment details
- static addresses
- unique
track_idreferences - HMAC-signed webhooks
- Payment Information
- Payment History
- PHP, Python, and Laravel SDKs
The module provides the SaaS logic:
- billing ownership
- plan definitions
- checkout purpose
- subscription terms
- entitlements
- renewal invoices
- grace periods
- access enforcement
- support visibility
- reconciliation
- recovery
Start with one framework.
Support one plan-purchase flow.
Make every payment grant access exactly once.
Make every access change explainable.
Make every failure visible.
That is how a crypto payment integration becomes reusable SaaS billing infrastructure.
Would you build the first version for Laravel, Node.js, Django, or Next.js?
Related articles
- 10 Crypto Payment Products Developers Can Build for Merchants
- Build a Crypto PaymentOps Service for Merchants
- Build a Vertical Crypto Checkout for Hosting Providers
- Build a Payment Automation Studio for Crypto Merchants
- Build a Crypto Payment Reconciliation Tool for Merchants
- Build a Crypto Payment Support Desk
Top comments (0)