A customer pays $100 for an online course.
The platform keeps $20.
The instructor earns $70.
An affiliate earns $10.
The calculation looks simple.
The operational system is not.
What happens when:
- the payment webhook arrives twice
- the split rule changes after the sale
- the instructor updates their payout address
- a refund occurs after earnings were created
- the payout API times out after receiving the request
- the payout is rejected
- two workers create a payout for the same balance
- the partner disputes the amount
- finance asks why a balance changed three months later
A basic payment integration cannot answer those questions.
A Crypto Revenue Split and Payout System connects confirmed customer payments to explainable partner earnings and controlled outbound payouts.
The core flow is:
Confirmed payment
-> Immutable financial journal
-> Versioned revenue allocation
-> Pending or available earnings
-> Payout reservation
-> Approval
-> Provider payout
-> Confirmation or release
-> Reconciliation
This article uses OxaPay for payment and payout execution, but the architecture is provider-agnostic.
This article is part of 10 Crypto Payment Products Developers Can Build for Merchants.
The payout API is not the product
OxaPay can provide the infrastructure needed to:
- create customer invoices
- receive signed payment callbacks
- retrieve Payment Information
- search Payment History
- create payout requests
- retrieve Payout Information
- search Payout History
- receive signed payout callbacks
Your application still needs to decide:
- which payment creates earnings
- which split rule applies
- which version of that rule applies
- whether the allocation is based on gross or net revenue
- when earnings become available
- whether a reserve or holdback is required
- which payout method is valid
- who may approve a payout
- when a rejected payout returns to the partner balance
- how refunds and corrections are recorded
- how every financial movement can be explained
OxaPay is the execution infrastructure.
The merchant product is the financial operations layer around it.
Payment, earnings, and payouts are different facts
Do not collapse the entire process into:
payment received
-> partner paid
A reliable model separates at least four concepts.
Customer payment
Evidence that money was received for an order.
Revenue allocation
The rule-based distribution of the allocable payment amount.
Partner payable
The amount the merchant currently owes a partner.
Provider payout
An instruction sent through OxaPay to move funds to a destination address.
These states can disagree temporarily.
Payment: paid
Partner earning: pending
Available balance: 0
Payout: none
Or:
Payment: paid
Partner earning: available
Payout: reserved
Provider status: confirming
Or:
Payment: refunded
Partner payout: already confirmed
Partner balance: negative
Operational case: open
Those are real operational states.
One balance column cannot model them safely.
Define the allocation basis
Before calculating a split, define which amount is being divided.
Possible bases include:
Gross order value
Customer order value: 100
Split basis: 100
Net received amount
Customer order value: 100
Provider and network costs: 1
Split basis: 99
Net amount after merchant deductions
Customer order value: 100
Provider costs: 1
Refund reserve: 5
Split basis: 94
There is no universal answer.
The merchant policy must define:
allocation_basis = gross_order_value
or:
allocation_basis = net_received
or another explicit model.
Do not calculate some sales using gross value and others using net value without storing that decision.
Every allocation should preserve:
- original order amount
- pricing currency
- received asset
- received amount
- fee information when available
- allocable amount
- allocation currency
- applied split-rule version
- rounding policy
Use a double-entry journal
A simple table containing positive and negative partner amounts is better than an editable balance.
A balanced double-entry journal is stronger.
Each journal transaction contains lines whose total debits equal total credits in the same accounting unit.
For an allocable amount of 100 USDT:
Debit Merchant funds control 100 USDT
Credit Platform revenue 20 USDT
Credit Partner payable: instructor 70 USDT
Credit Partner payable: affiliate 10 USDT
The journal answers:
Where did the value come from?
Who owns the value now?
Which payment created it?
Which rule determined it?
When an instructor payout of 70 USDT is reserved:
Debit Partner payable: instructor 70 USDT
Credit Payouts in transit: instructor 70 USDT
When the payout is confirmed:
Debit Payouts in transit: instructor 70 USDT
Credit Merchant funds control 70 USDT
If the payout is rejected:
Debit Payouts in transit: instructor 70 USDT
Credit Partner payable: instructor 70 USDT
The rejected payout returns to the payable balance through a new journal transaction.
Nothing is deleted or rewritten.
The core invariants
Define these rules before building the dashboard.
One provider payment is recognized at most once.
Every journal transaction is balanced.
Every allocation references an immutable split-rule version.
Past journal entries cannot be edited or deleted.
Corrections use reversal or adjustment transactions.
Pending earnings cannot be paid.
Reserved earnings cannot be reserved again.
Every payout item references one verified payout-method version.
Approval applies to an immutable payout snapshot.
A confirmed payout creates one settlement journal transaction.
A rejected or canceled payout releases one reservation.
Payment and payout webhooks use different API keys.
A network timeout during payout dispatch is not automatically treated as failure.
Every manual financial action records actor, time, reason, and evidence.
These invariants are the real product specification.
The architecture
+----------------------+
| Customer Checkout |
+----------+-----------+
|
| Create order and invoice
v
+----------------------+
| OxaPay Payment API |
+----------+-----------+
|
| Signed payment callback
v
+----------------------+
| Payment Webhook |
+----------+-----------+
|
| Verify and persist
v
+----------------------+
| Event Store + Outbox |
+----------+-----------+
|
v
+----------------------+
| Payment Worker |
+----------+-----------+
|
| Verify Payment Information
v
+----------------------+
| Ledger + Split Engine|
+----------+-----------+
|
| Pending / available earnings
v
+----------------------+
| Payout Scheduler |
+----------+-----------+
|
| Reserve balances
v
+----------------------+
| Approval Workflow |
+----------+-----------+
|
| Approved payout instruction
v
+----------------------+
| OxaPay Payout API |
+----------+-----------+
|
| Signed payout callback
v
+----------------------+
| Payout Reconciliation|
+----------+-----------+
|
v
Partner Dashboard / Finance / Support
Payment recognition and payout execution should be separate services or processing stages.
Do not call the Payout API from the payment webhook.
OxaPay primitives used
Generate Invoice
Create the customer-facing payment session:
POST https://api.oxapay.com/v1/payment/invoice
Authenticate with:
merchant_api_key
Payment Information
Verify one payment by track_id:
GET https://api.oxapay.com/v1/payment/{track_id}
Payment History
Recover and reconcile incoming payments:
GET https://api.oxapay.com/v1/payment
Generate Payout
Create an outbound payout request:
POST https://api.oxapay.com/v1/payout
Authenticate with:
payout_api_key
Payout Information
Retrieve one payout:
GET https://api.oxapay.com/v1/payout/{track_id}
Payout History
Recover and reconcile outbound payouts:
GET https://api.oxapay.com/v1/payout
Webhooks
Payment callbacks are verified using the Merchant API Key.
Payout callbacks are verified using the Payout API Key.
Both use HMAC SHA-512 over the raw request body and send the signature in the HMAC header.
Keep payment and payout callback routes separate.
A financial data model
CREATE TABLE merchants (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
accounting_currency TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE partners (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
partner_type TEXT NOT NULL,
display_name TEXT NOT NULL,
email TEXT,
status TEXT NOT NULL DEFAULT 'active',
payout_blocked BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE payout_method_versions (
id UUID PRIMARY KEY,
partner_id UUID NOT NULL REFERENCES partners(id),
version INTEGER NOT NULL,
currency TEXT NOT NULL,
network TEXT,
address TEXT NOT NULL,
memo TEXT,
status TEXT NOT NULL DEFAULT 'pending_verification',
created_by UUID,
verified_by UUID,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
verified_at TIMESTAMP,
disabled_at TIMESTAMP,
UNIQUE (partner_id, version)
);
CREATE TABLE split_rules (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE split_rule_versions (
id UUID PRIMARY KEY,
split_rule_id UUID NOT NULL REFERENCES split_rules(id),
version INTEGER NOT NULL,
allocation_basis TEXT NOT NULL,
config JSONB NOT NULL,
effective_from TIMESTAMP NOT NULL,
effective_until TIMESTAMP,
created_by UUID,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (split_rule_id, version)
);
CREATE TABLE orders (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
external_order_id TEXT NOT NULL,
split_rule_version_id UUID NOT NULL
REFERENCES split_rule_versions(id),
order_amount NUMERIC(36, 18) NOT NULL,
pricing_currency TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'created',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (merchant_id, external_order_id)
);
CREATE TABLE payments (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
order_id UUID NOT NULL REFERENCES orders(id),
provider TEXT NOT NULL DEFAULT 'oxapay',
provider_track_id TEXT NOT NULL,
provider_status TEXT NOT NULL,
internal_status TEXT NOT NULL,
requested_amount NUMERIC(36, 18) NOT NULL,
received_amount NUMERIC(36, 18),
received_currency TEXT,
network TEXT,
paid_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (provider, provider_track_id)
);
CREATE TABLE provider_events (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
event_type TEXT NOT NULL,
provider_track_id TEXT,
payload_hash TEXT NOT NULL,
signature_valid BOOLEAN NOT NULL,
raw_payload JSONB NOT NULL,
source TEXT NOT NULL,
received_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (merchant_id, event_type, payload_hash)
);
CREATE TABLE journal_transactions (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
business_key TEXT NOT NULL,
transaction_type TEXT NOT NULL,
reference_type TEXT NOT NULL,
reference_id UUID NOT NULL,
currency TEXT NOT NULL,
description TEXT,
metadata JSONB,
posted_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (merchant_id, business_key)
);
CREATE TABLE journal_lines (
id UUID PRIMARY KEY,
journal_transaction_id UUID NOT NULL
REFERENCES journal_transactions(id),
account_code TEXT NOT NULL,
partner_id UUID REFERENCES partners(id),
side TEXT NOT NULL CHECK (side IN ('debit', 'credit')),
amount NUMERIC(36, 18) NOT NULL CHECK (amount > 0),
metadata JSONB,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE earning_allocations (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
payment_id UUID NOT NULL REFERENCES payments(id),
partner_id UUID REFERENCES partners(id),
split_rule_version_id UUID NOT NULL
REFERENCES split_rule_versions(id),
allocation_type TEXT NOT NULL,
amount NUMERIC(36, 18) NOT NULL,
currency TEXT NOT NULL,
availability_status TEXT NOT NULL DEFAULT 'pending',
available_at TIMESTAMP,
reversed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (payment_id, partner_id, allocation_type)
);
CREATE TABLE payout_batches (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
status TEXT NOT NULL DEFAULT 'draft',
item_count INTEGER NOT NULL DEFAULT 0,
total_amount NUMERIC(36, 18),
currency TEXT,
snapshot_hash TEXT,
created_by UUID NOT NULL,
approved_by UUID,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
frozen_at TIMESTAMP,
approved_at TIMESTAMP
);
CREATE TABLE payout_items (
id UUID PRIMARY KEY,
batch_id UUID NOT NULL REFERENCES payout_batches(id),
merchant_id UUID NOT NULL REFERENCES merchants(id),
partner_id UUID NOT NULL REFERENCES partners(id),
payout_method_version_id UUID NOT NULL
REFERENCES payout_method_versions(id),
amount NUMERIC(36, 18) NOT NULL,
currency TEXT NOT NULL,
network TEXT,
status TEXT NOT NULL DEFAULT 'draft',
provider TEXT NOT NULL DEFAULT 'oxapay',
provider_track_id TEXT,
dispatch_attempt_key TEXT NOT NULL UNIQUE,
last_error TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
approved_at TIMESTAMP,
sent_at TIMESTAMP,
confirmed_at TIMESTAMP,
UNIQUE (provider, provider_track_id)
);
CREATE TABLE payout_reservations (
id UUID PRIMARY KEY,
payout_item_id UUID NOT NULL REFERENCES payout_items(id),
earning_allocation_id UUID NOT NULL REFERENCES earning_allocations(id),
reserved_amount NUMERIC(36, 18) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (payout_item_id, earning_allocation_id)
);
CREATE TABLE approvals (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
entity_type TEXT NOT NULL,
entity_id UUID NOT NULL,
approval_type TEXT NOT NULL,
actor_id UUID NOT NULL,
snapshot_hash TEXT NOT NULL,
decision TEXT NOT NULL,
reason TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE operational_cases (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
entity_type TEXT NOT NULL,
entity_id UUID,
case_type TEXT NOT NULL,
severity TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
summary TEXT NOT NULL,
evidence JSONB,
recommended_action TEXT,
resolution_note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMP
);
CREATE TABLE outbox_jobs (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
topic TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempt_count INTEGER NOT NULL DEFAULT 0,
available_at TIMESTAMP NOT NULL DEFAULT NOW(),
published_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
The schema is larger than a normal checkout integration because the financial responsibilities are larger.
Freeze the split rule at order creation
Do not look up the currently active split rule after payment.
The rule may have changed since the customer created the order.
Store the exact split-rule version on the order:
Order ORD-1042
Split rule: Course Revenue
Version: 4
Suppose version 4 says:
Platform: 20%
Instructor: 70%
Affiliate: 10%
Version 5 may later change the platform fee to 25%.
Order ORD-1042 must still use version 4.
Without rule versioning, historical partner balances can change when configuration changes.
Validate the split rule
A percentage-based split must satisfy a defined total.
import Decimal from "decimal.js";
export function validatePercentageRule(
allocations,
) {
const total = allocations.reduce(
(sum, allocation) =>
sum.plus(
new Decimal(allocation.percent),
),
new Decimal(0),
);
if (!total.equals(100)) {
throw new Error(
`Split percentages must total 100. Received ${total.toString()}`,
);
}
}
If the product supports reserves, define whether reserve is part of the 100% allocation.
For example:
Platform revenue: 20%
Instructor payable: 65%
Affiliate payable: 10%
Reserve liability: 5%
Total: 100%
Do not allow an unexplained remainder.
Receive payment webhooks safely
Use a merchant-specific endpoint so the application can load the correct Merchant API Key before trusting the payload.
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post(
"/webhooks/oxapay/payment/:endpointId",
express.raw({
type: "application/json",
}),
async (req, res) => {
const merchant =
await loadMerchantByPaymentEndpoint(
req.params.endpointId,
);
if (!merchant) {
return res
.status(404)
.send("unknown endpoint");
}
const rawBody = req.body;
const receivedHmac =
req.get("HMAC");
const merchantApiKey =
await decryptSecret(
merchant
.oxapayMerchantApiKeyEncrypted,
);
const expectedHmac = crypto
.createHmac(
"sha512",
merchantApiKey,
)
.update(rawBody)
.digest("hex");
if (
!safeEqualSha512(
receivedHmac,
expectedHmac,
)
) {
await recordRejectedEvent({
merchantId: merchant.id,
eventType: "payment",
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 persistPaymentEventAndOutbox({
merchant,
payload,
payloadHash,
});
return res
.status(200)
.send("ok");
} catch (error) {
console.error(
"Payment event persistence failed",
error,
);
return res
.status(500)
.send("failed");
}
},
);
function safeEqualSha512(
received,
expected,
) {
const validHex =
/^[a-f0-9]{128}$/i;
if (
!received ||
!expected ||
!validHex.test(received) ||
!validHex.test(expected)
) {
return false;
}
return crypto.timingSafeEqual(
Buffer.from(received, "hex"),
Buffer.from(expected, "hex"),
);
}
Store the provider event and outbox job inside one database transaction.
Do not calculate splits inside the webhook request.
Recognize the payment once
Before posting financial entries:
- Load the payment by
track_id. - Retrieve current Payment Information.
- Verify the provider status.
- Verify the order mapping.
- Verify the amount policy.
- Load the frozen split-rule version.
- Calculate allocations.
- Post one balanced journal transaction.
- Create earning-allocation records.
- Mark the payment recognized.
export async function recognizePaidPayment({
merchant,
payment,
providerPayment,
}) {
if (
String(providerPayment.status)
.toLowerCase() !== "paid"
) {
throw new PermanentFinanceError(
"Provider payment is not paid",
);
}
const businessKey =
`payment-recognition:${payment.id}`;
return db.$transaction(async (tx) => {
const existing =
await tx.journalTransaction.findUnique({
where: {
merchantId_businessKey: {
merchantId: merchant.id,
businessKey,
},
},
});
if (existing) {
return existing;
}
const order =
await tx.order.findUnique({
where: { id: payment.orderId },
include: {
splitRuleVersion: true,
},
});
if (!order) {
throw new PermanentFinanceError(
"Order not found",
);
}
if (
String(providerPayment.order_id) !==
String(order.externalOrderId)
) {
throw new PermanentFinanceError(
"Payment order_id mismatch",
);
}
const allocableAmount =
calculateAllocableAmount({
order,
payment,
providerPayment,
allocationBasis:
order.splitRuleVersion
.allocationBasis,
});
const allocations =
calculateAllocations({
amount: allocableAmount,
rule:
order.splitRuleVersion.config,
});
assertAllocationTotal({
allocations,
allocableAmount,
});
const journal =
await tx.journalTransaction.create({
data: {
merchantId: merchant.id,
businessKey,
transactionType:
"revenue_allocation",
referenceType: "payment",
referenceId: payment.id,
currency:
providerPayment.currency,
description:
`Revenue allocation for ${order.externalOrderId}`,
metadata: {
splitRuleVersionId:
order.splitRuleVersionId,
allocationBasis:
order.splitRuleVersion
.allocationBasis,
allocableAmount:
allocableAmount.toString(),
},
},
});
await tx.journalLine.create({
data: {
journalTransactionId:
journal.id,
accountCode:
"merchant_funds_control",
side: "debit",
amount:
allocableAmount.toString(),
},
});
for (const allocation of allocations) {
await tx.journalLine.create({
data: {
journalTransactionId:
journal.id,
accountCode:
allocation.accountCode,
partnerId:
allocation.partnerId ?? null,
side: "credit",
amount:
allocation.amount.toString(),
metadata: {
allocationType:
allocation.type,
},
},
});
if (allocation.partnerId) {
await tx.earningAllocation.create({
data: {
merchantId: merchant.id,
paymentId: payment.id,
partnerId:
allocation.partnerId,
splitRuleVersionId:
order.splitRuleVersionId,
allocationType:
allocation.type,
amount:
allocation.amount.toString(),
currency:
providerPayment.currency,
availabilityStatus:
allocation.availableAt >
new Date()
? "pending"
: "available",
availableAt:
allocation.availableAt,
},
});
}
}
await tx.payment.update({
where: { id: payment.id },
data: {
providerStatus: "paid",
internalStatus:
"revenue_recognized",
receivedAmount:
providerPayment.amount,
receivedCurrency:
providerPayment.currency,
network:
providerPayment.network,
paidAt: new Date(),
},
});
return journal;
});
}
calculateAllocableAmount and calculateAllocations must use a decimal library and explicit rounding rules.
Handle rounding deliberately
Percentage calculations can produce fractional remainders.
Example:
Allocable amount: 10 USDT
Three partners: 33.33% each
The mathematical result does not divide cleanly at every precision.
Define:
- calculation precision
- payout precision
- rounding mode
- remainder account
One policy is:
Calculate all shares at supported precision.
Round each partner share down.
Allocate the final remainder to a rounding account or the platform.
The policy must be consistent and visible.
Do not let each programming language or database driver choose its own rounding behavior.
Pending, available, reserved, and paid
A partner earning should move through explicit availability states.
pending
-> available
-> reserved
-> paid
Exception states can include:
reversed
disputed
blocked
Pending
The earning exists but cannot yet be paid.
Possible reasons:
- refund window
- service delivery window
- reserve period
- fraud review
- merchant policy
Available
The earning can be included in a payout batch.
Reserved
The earning has been claimed by one payout item.
It must not be included elsewhere.
Paid
The associated provider payout was confirmed.
Do not derive this lifecycle only from payout-item status.
Keep the relationship between earning allocations and payout reservations explicit.
Refunds and reversals
Never delete an original revenue allocation after a refund.
Post a reversal.
For a full reversal:
Debit Platform revenue
Debit Partner payable: instructor
Debit Partner payable: affiliate
Credit Merchant funds control
The exact journal depends on whether the partner earning is:
- pending
- available
- reserved
- already paid
If the partner was already paid, the system may create:
Negative partner balance
or assign the loss according to merchant policy.
That decision is not purely technical.
Your system should expose the financial condition and require an authorized policy.
Useful cases include:
refund_before_payout
refund_after_reservation
refund_after_confirmed_payout
partial_refund
manual_revenue_adjustment
Each case should produce new journal entries and audit evidence.
Version payout methods
Do not edit a wallet address in place.
Create payout-method versions.
Partner payout method version 1
USDT / Tron / Address A
Disabled
Partner payout method version 2
USDT / Tron / Address B
Verified
Historical payout items must continue to reference the exact version they used.
A secure address-change flow can be:
Partner submits new address
-> New version created as pending
-> Existing address remains active
-> Confirmation is sent
-> Authorized reviewer verifies change
-> Cooldown begins
-> New version becomes active
-> Old version is disabled
Recommended controls:
- separate submitter and verifier for high-value accounts
- notification to the previous contact method
- cooldown after address changes
- payout block during unresolved changes
- full audit trail
- address and network validation
- memo or destination-tag validation when required
A payout address is financial configuration.
Treat it like one.
Create payout batches from available earnings
The batch builder should:
- Select available earnings.
- Apply minimum thresholds.
- Exclude blocked partners.
- Load the verified payout-method version.
- Lock selected earnings.
- Create payout items.
- Create reservation records.
- Post reservation journal entries.
- Freeze the batch snapshot.
This must happen transactionally.
export async function buildPayoutBatch({
merchantId,
currency,
createdBy,
}) {
return db.$transaction(
async (tx) => {
const batch =
await tx.payoutBatch.create({
data: {
merchantId,
status: "draft",
currency,
createdBy,
},
});
const partners =
await findEligiblePartnerBalances(
tx,
{
merchantId,
currency,
},
);
for (const partner of partners) {
if (
partner.availableBalance.lessThan(
partner.minimumPayout,
)
) {
continue;
}
const payoutMethod =
await tx.payoutMethodVersion.findFirst({
where: {
partnerId: partner.id,
currency,
status: "verified",
disabledAt: null,
},
orderBy: {
version: "desc",
},
});
if (!payoutMethod) {
await createOperationalCaseTx(
tx,
{
merchantId,
entityType: "partner",
entityId: partner.id,
caseType:
"missing_verified_payout_method",
severity: "medium",
summary:
"Partner has available earnings but no verified payout method.",
},
);
continue;
}
const allocations =
await lockAvailableAllocations(
tx,
{
partnerId: partner.id,
currency,
maximumAmount:
partner.availableBalance,
},
);
const payoutAmount =
sumAllocationAmounts(
allocations,
);
const payoutItem =
await tx.payoutItem.create({
data: {
batchId: batch.id,
merchantId,
partnerId: partner.id,
payoutMethodVersionId:
payoutMethod.id,
amount:
payoutAmount.toString(),
currency,
network:
payoutMethod.network,
status: "reserved",
dispatchAttemptKey:
`payout:${batch.id}:${partner.id}`,
},
});
for (const allocation of allocations) {
await tx.payoutReservation.create({
data: {
payoutItemId:
payoutItem.id,
earningAllocationId:
allocation.id,
reservedAmount:
allocation.amount,
},
});
await tx.earningAllocation.update({
where: {
id: allocation.id,
},
data: {
availabilityStatus:
"reserved",
},
});
}
await postPayoutReservationJournal(
tx,
{
merchantId,
payoutItem,
partnerId: partner.id,
amount: payoutAmount,
currency,
},
);
}
const summary =
await calculateBatchSummary(
tx,
batch.id,
);
const snapshotHash =
hashPayoutBatchSnapshot(summary);
return tx.payoutBatch.update({
where: { id: batch.id },
data: {
status: "frozen",
itemCount:
summary.itemCount,
totalAmount:
summary.totalAmount,
snapshotHash,
frozenAt: new Date(),
},
});
},
{
isolationLevel: "Serializable",
},
);
}
The database and ORM syntax will vary.
The important requirement is that two concurrent workers cannot reserve the same earning.
Approval must apply to a frozen snapshot
An approval should not mean:
Someone clicked Approve.
It should mean:
An authorized person approved this exact collection of partners, amounts, assets, networks, and addresses.
Calculate a snapshot hash from:
- batch ID
- payout-item IDs
- partner IDs
- amounts
- currencies
- networks
- payout-method version IDs
- addresses or address hashes
When approving:
- Recalculate the hash.
- Compare it with the frozen hash.
- Reject approval if anything changed.
- Store the hash in the approval record.
- Move the batch to
approved.
If the batch changes, invalidate the approval and freeze a new version.
Use maker-checker controls
For sensitive payouts, the person creating the batch should not approve it.
export async function approvePayoutBatch({
batchId,
actor,
}) {
const batch =
await loadFrozenBatch(batchId);
if (batch.createdBy === actor.id) {
throw new Error(
"Batch creator cannot approve this batch",
);
}
assertRoleAllowed(
actor,
"approve_payout_batch",
);
const currentSnapshot =
await calculateBatchSnapshot(
batch.id,
);
const currentHash =
hashPayoutBatchSnapshot(
currentSnapshot,
);
if (
currentHash !== batch.snapshotHash
) {
throw new Error(
"Batch changed after it was frozen",
);
}
await db.$transaction([
db.approval.create({
data: {
merchantId:
batch.merchantId,
entityType:
"payout_batch",
entityId: batch.id,
approvalType:
"financial_approval",
actorId: actor.id,
snapshotHash:
currentHash,
decision: "approved",
},
}),
db.payoutBatch.update({
where: { id: batch.id },
data: {
status: "approved",
approvedBy: actor.id,
approvedAt: new Date(),
},
}),
db.payoutItem.updateMany({
where: {
batchId: batch.id,
status: "reserved",
},
data: {
status: "approved",
approvedAt: new Date(),
},
}),
]);
}
Additional controls can include:
- two approvals above a threshold
- daily merchant payout limit
- per-partner limit
- batch-size limit
- payout blackout after address change
- blocked partner list
- high-value manual review
- separate finance and operations roles
Dispatch payouts from a worker
Never execute payouts in the browser or directly from an approval request.
The worker should load an approved payout item and its verified payout-method version.
const OXAPAY_API =
"https://api.oxapay.com/v1";
export async function dispatchPayoutItem({
payoutItemId,
}) {
const item =
await lockApprovedPayoutItem(
payoutItemId,
);
if (!item) {
return;
}
if (item.providerTrackId) {
return item;
}
const merchant =
await loadMerchant(item.merchantId);
const payoutMethod =
await loadPayoutMethodVersion(
item.payoutMethodVersionId,
);
assertPayoutMethodUsable({
partner: item.partner,
payoutMethod,
payoutItem: item,
});
await db.payoutItem.update({
where: { id: item.id },
data: {
status: "dispatching",
},
});
const payoutApiKey =
await decryptSecret(
merchant
.oxapayPayoutApiKeyEncrypted,
);
try {
const response = await fetch(
`${OXAPAY_API}/payout`,
{
method: "POST",
headers: {
"Content-Type":
"application/json",
payout_api_key:
payoutApiKey,
},
body: JSON.stringify({
address:
payoutMethod.address,
amount: item.amount,
currency: item.currency,
network:
item.network || undefined,
memo:
payoutMethod.memo || undefined,
callback_url:
`${process.env.APP_URL}/webhooks/oxapay/payout/${merchant.payoutWebhookEndpointId}`,
description:
`Payout item ${item.id}`,
}),
signal:
AbortSignal.timeout(15000),
},
);
const payload = await response.json();
if (!response.ok) {
await markExplicitPayoutFailure({
payoutItemId: item.id,
payload,
});
throw new Error(
payload?.error?.message ??
`Payout request failed with ${response.status}`,
);
}
const payout = payload.data;
return db.payoutItem.update({
where: { id: item.id },
data: {
status:
"provider_processing",
providerTrackId:
String(payout.track_id),
providerStatus:
payout.status ??
"processing",
sentAt: new Date(),
},
});
} catch (error) {
if (isAmbiguousDispatchError(error)) {
await db.payoutItem.update({
where: { id: item.id },
data: {
status:
"dispatch_unknown",
lastError: error.message,
},
});
await createOperationalCase({
merchantId:
item.merchantId,
entityType: "payout_item",
entityId: item.id,
caseType:
"payout_dispatch_outcome_unknown",
severity: "critical",
summary:
"The payout request may have reached the provider, but no conclusive response was received.",
recommendedAction:
"Reconcile with provider records before retrying.",
});
return;
}
throw error;
}
}
Do not blindly retry an ambiguous payout
This is one of the most important rules in the article.
Consider:
Your worker sends the payout request.
OxaPay accepts it.
The network connection closes before your application receives the response.
Your application does not have a track_id.
It also cannot prove the payout was rejected.
Blindly retrying can create a second payout.
Mark the item:
dispatch_unknown
Then reconcile using:
- Payout History
- account records
- destination
- amount
- currency
- network
- approximate dispatch time
- description or internal evidence when available
Only retry after the first outcome is resolved.
A checkout timeout may delay an order.
A payout timeout can duplicate money movement.
Use a stricter retry policy.
Validate payout webhooks separately
Payout callbacks use the Payout API Key as their HMAC secret.
app.post(
"/webhooks/oxapay/payout/:endpointId",
express.raw({
type: "application/json",
}),
async (req, res) => {
const merchant =
await loadMerchantByPayoutEndpoint(
req.params.endpointId,
);
if (!merchant) {
return res
.status(404)
.send("unknown endpoint");
}
const rawBody = req.body;
const receivedHmac =
req.get("HMAC");
const payoutApiKey =
await decryptSecret(
merchant
.oxapayPayoutApiKeyEncrypted,
);
const expectedHmac = crypto
.createHmac(
"sha512",
payoutApiKey,
)
.update(rawBody)
.digest("hex");
if (
!safeEqualSha512(
receivedHmac,
expectedHmac,
)
) {
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 persistPayoutEventAndOutbox({
merchant,
payload,
payloadHash,
});
return res
.status(200)
.send("ok");
} catch (error) {
console.error(
"Payout event persistence failed",
error,
);
return res
.status(500)
.send("failed");
}
},
);
Never validate payout callbacks with the Merchant API Key.
Never use the Payout API Key for payment callbacks.
Map provider payout states
OxaPay payout statuses currently include:
processing
pending
confirming
confirmed
canceled
rejected
Map them into internal operational states.
const PAYOUT_STATUS_MAP = {
processing:
"provider_processing",
pending:
"provider_pending",
confirming:
"blockchain_confirming",
confirmed:
"completed",
canceled:
"canceled",
rejected:
"failed",
};
export function mapPayoutStatus(
providerStatus,
) {
return (
PAYOUT_STATUS_MAP[
String(providerStatus ?? "")
.trim()
.toLowerCase()
] ?? "needs_review"
);
}
The provider state is evidence.
The internal state determines which accounting and operational action should occur.
Settle or release exactly once
When a payout becomes confirmed:
- Mark the payout item completed.
- Post one payout-settlement journal.
- Mark reserved allocations paid.
- Close the operational instruction.
When a payout becomes rejected or canceled:
- Move the item to review or released.
- Post one reservation-release journal.
- Return associated allocations to available status.
- Preserve the provider event.
export async function applyPayoutStatus({
payoutItem,
providerStatus,
providerPayload,
}) {
const internalStatus =
mapPayoutStatus(providerStatus);
if (internalStatus === "completed") {
return settleConfirmedPayout({
payoutItem,
providerPayload,
});
}
if (
internalStatus === "failed" ||
internalStatus === "canceled"
) {
return releaseRejectedPayout({
payoutItem,
providerPayload,
internalStatus,
});
}
return db.payoutItem.update({
where: {
id: payoutItem.id,
},
data: {
providerStatus,
status: internalStatus,
},
});
}
Settlement:
async function settleConfirmedPayout({
payoutItem,
providerPayload,
}) {
const businessKey =
`payout-settlement:${payoutItem.id}`;
return db.$transaction(async (tx) => {
const existing =
await tx.journalTransaction.findUnique({
where: {
merchantId_businessKey: {
merchantId:
payoutItem.merchantId,
businessKey,
},
},
});
if (existing) {
return existing;
}
const journal =
await tx.journalTransaction.create({
data: {
merchantId:
payoutItem.merchantId,
businessKey,
transactionType:
"payout_settlement",
referenceType:
"payout_item",
referenceId:
payoutItem.id,
currency:
payoutItem.currency,
description:
`Confirmed payout ${payoutItem.id}`,
metadata: {
providerTrackId:
payoutItem.providerTrackId,
providerStatus:
providerPayload.status,
},
},
});
await tx.journalLine.createMany({
data: [
{
journalTransactionId:
journal.id,
accountCode:
"payouts_in_transit",
partnerId:
payoutItem.partnerId,
side: "debit",
amount:
payoutItem.amount,
},
{
journalTransactionId:
journal.id,
accountCode:
"merchant_funds_control",
side: "credit",
amount:
payoutItem.amount,
},
],
});
const reservations =
await tx.payoutReservation.findMany({
where: {
payoutItemId:
payoutItem.id,
},
});
await tx.earningAllocation.updateMany({
where: {
id: {
in: reservations.map(
(reservation) =>
reservation
.earningAllocationId,
),
},
},
data: {
availabilityStatus: "paid",
},
});
await tx.payoutItem.update({
where: {
id: payoutItem.id,
},
data: {
status: "completed",
providerStatus: "confirmed",
confirmedAt: new Date(),
},
});
return journal;
});
}
The unique businessKey prevents duplicate payout callbacks from posting the settlement twice.
Account for payout fees
If the payout creates a separate fee, define who pays it.
Possible policies:
Merchant pays payout fee
Partner pays payout fee
Fee shared by policy
The ledger should record the result explicitly.
Merchant-paid example:
Debit Payout fee expense
Credit Merchant funds control
Partner-paid example:
Debit Partner payable
Credit Payout fee recovery
Do not silently reduce the sent amount without showing the partner and merchant how it was calculated.
Reconcile payments and payouts
Webhooks are the real-time path.
Provider information and history endpoints are recovery paths.
Payment reconciliation
Use Payment Information and Payment History to detect:
Provider payment is paid
Local payment is not recognized
Payment recognized
No balanced revenue-allocation journal exists
Journal exists
Earning allocations are missing
Payment was refunded
Original allocations remain unreversed
Payout reconciliation
Use Payout Information and Payout History to detect:
Payout item is dispatch_unknown
Provider payout exists
Local provider_track_id is missing
Provider status is confirmed
Local item is still confirming
Local item is completed
No settlement journal exists
Provider payout was rejected
Reservation remains locked
Unknown provider payout exists
No internal payout item matches it
A practical schedule:
Every 10 minutes:
- refresh open provider payouts
- resolve stale processing states
- investigate dispatch_unknown items
Every hour:
- compare approved and dispatched items
- report unusually old payouts
Every night:
- reconcile Payment History
- reconcile Payout History
- validate balanced journals
- validate reservation states
- produce unresolved-case report
Recovered events should identify their source:
payment_history_backfill
payout_history_backfill
manual_provider_refresh
Evidence provenance matters.
The partner dashboard
A partner should be able to understand their balance without seeing merchant-wide financial data.
Show:
- pending earnings
- available earnings
- reserved amount
- paid amount
- negative adjustments
- payout method
- payout history
- expected availability date
- allocation source
- payout status
For each earning:
Order: ORD-1042
Type: Instructor share
Gross allocation basis: 100 USDT
Your share: 70 USDT
Rule version: 4
Status: Available
For each payout:
Payout item: PAY-882
Amount: 350 USDT
Network: Tron
Address version: 3
Status: Confirming
Do not expose:
- other partners
- merchant API credentials
- internal risk notes
- unrestricted raw payloads
- private merchant balances
Authorization must be enforced by merchant and partner scope on every query.
The merchant dashboard
The merchant needs several operational views.
Revenue allocation
- paid order
- allocable amount
- split-rule version
- platform amount
- partner amounts
- reserve
- rounding remainder
Partner balances
- pending
- available
- reserved
- paid
- negative or disputed
Payout batches
- creator
- frozen snapshot
- approver
- item count
- total
- status
- failed items
Needs attention
- invalid split total
- payment without order
- revenue not allocated
- partner missing payout method
- address changed during payout cycle
- dispatch outcome unknown
- rejected payout
- canceled payout
- confirmed payout without settlement journal
- refund after partner payout
- negative partner balance
- unmatched provider payout
Audit history
- rule changes
- address changes
- manual adjustments
- batch creation
- approvals
- dispatch
- settlement
- release
- overrides
The product should make every important number explainable.
Support should trace the full chain
Support and finance should be able to follow:
Customer payment
-> Order
-> Revenue allocation
-> Partner earning
-> Payout reservation
-> Payout item
-> Provider track_id
-> Final journal
Search fields should include:
- order ID
- payment
track_id - partner
- payout item ID
- payout
track_id - payout address
- transaction hash
- split-rule version
- date range
- provider status
- internal status
This is where the system connects to the Crypto Payment Reconciliation Tool and Crypto Payment Support Desk.
Compliance and responsibility boundaries
Revenue-sharing and payout software can affect:
- custody analysis
- partner onboarding
- sanctions controls
- tax reporting
- marketplace obligations
- contractor relationships
- merchant-of-record responsibilities
- regional licensing
- consumer refunds
The software does not automatically resolve those obligations.
A safer product model is:
The merchant owns the commercial relationship.
The merchant owns the OxaPay payment and payout accounts.
The merchant defines split, reserve, approval, and payout policies.
The merchant authorizes payout batches.
The software calculates, records, queues, executes, and tracks instructions under those policies.
Do not market the system as a way to bypass financial or legal responsibilities.
The MVP
Choose one narrow use case.
A strong first niche is:
Course platform
Support:
Platform share
Instructor share
Optional affiliate share
Build:
- one merchant
- OxaPay invoice creation
- HMAC-validated payment webhook
- Payment Information verification
- immutable double-entry journal
- one versioned percentage rule
- pending and available earnings
- partner records
- verified payout-method versions
- one payout currency and network
- minimum payout threshold
- manual batch creation
- balance reservation
- frozen batch snapshot
- maker-checker approval
- OxaPay payout execution
- separate payout webhook
- payout settlement and release journals
- partner dashboard
- merchant dashboard
- Payment and Payout History recovery
- CSV export
- complete audit log
Do not include initially:
- automatic payouts without approval
- several payout currencies
- currency conversion
- complex tiered commissions
- tax calculation
- multi-provider routing
- advanced risk scoring
- internal custodial wallets
- public payout API
- unlimited custom rules
The MVP should prove:
Every confirmed payment creates explainable earnings, and every outbound payout can be traced to those earnings exactly once.
Production safeguards
Before handling real merchant payouts, implement:
- separate Merchant and Payout API Keys
- encrypted secret storage
- strict tenant isolation
- raw-body HMAC verification
- timing-safe comparison
- immutable rule versions
- balanced journal validation
- decimal arithmetic
- event deduplication
- payment-recognition idempotency
- payout reservation locks
- serializable batch creation
- frozen approval snapshots
- maker-checker approval
- payout limits
- address versioning
- address-change cooldown
- payout worker isolation
- ambiguous-dispatch handling
- payout-settlement idempotency
- refund reversals
- Payment History recovery
- Payout History recovery
- role-based authorization
- audit logs
- operational alerts
- backup and restore testing
A payout system should fail closed.
When evidence is incomplete, stop and review.
Testing checklist
Test at least:
Invalid payment HMAC changes no state
Paying payment creates no final earnings
Paid payment is recognized exactly once
Duplicate payment payload creates no duplicate journal
Different paid callbacks create no duplicate allocation
Split percentages must total the expected amount
Rounding uses the defined policy
Historical order keeps its original rule version
Journal debits equal credits
Pending earnings cannot be reserved
The same earning cannot enter two payout items
Unverified payout address cannot be used
Address change invalidates the old approval path
Batch creator cannot approve the same batch
Changed batch snapshot cannot be executed
Payout key is never exposed to the frontend
Invalid payout HMAC changes no state
Duplicate confirmed payout callback creates one settlement
Rejected payout releases one reservation
Canceled payout releases one reservation
Ambiguous timeout is not retried automatically
Payment History recovers missing payment evidence
Payout History resolves stale payout status
Refund creates reversal entries
Partner cannot access another partner's balance
Support can trace payment to payout
These tests are part of the financial product.
They are not optional technical polish.
Product positioning
Weak positioning:
I integrate crypto payouts.
Better positioning:
I build crypto revenue-sharing dashboards for merchant platforms.
Stronger positioning:
A ledger-first system that converts confirmed customer payments into explainable partner earnings, approval-controlled crypto payouts, and fully traceable financial records.
Niche-specific positioning is stronger:
Crypto revenue sharing for course platforms: split paid course sales between the platform, instructors, and affiliates, then create controlled weekly payout batches with partner statements and audit logs.
The merchant is not purchasing a payout API call.
The merchant is purchasing control over money owed to other parties.
What makes this a real product?
A basic payout script says:
Payment paid
-> Calculate percentage
-> Send crypto
A production revenue split system says:
Verify confirmed payment
-> Freeze allocation basis
-> Apply historical split-rule version
-> Post balanced journal
-> Create pending earnings
-> Release earnings under merchant policy
-> Reserve available balances atomically
-> Freeze payout snapshot
-> Collect authorized approval
-> Dispatch through isolated worker
-> Handle ambiguous delivery safely
-> Track provider states
-> Settle or release exactly once
-> Reconcile provider and internal records
-> Preserve complete audit evidence
That is the difference between payout integration and financial operations software.
Final takeaway
The strongest opportunity is not sending crypto to partners.
It is building the control system between customer revenue and partner payouts.
OxaPay provides the execution primitives:
- customer invoices
- signed payment callbacks
- Payment Information
- Payment History
- payout requests
- signed payout callbacks
- Payout Information
- Payout History
- payout status tracking
- SDKs and automation integrations
The merchant product provides:
- allocation policy
- rule versioning
- balanced journals
- partner payables
- reserve periods
- payout-method verification
- batch reservation
- approval
- safe execution
- reconciliation
- partner reporting
- auditability
Start with one merchant type.
Use one allocation currency.
Support one split model.
Build the journal first.
Reserve before sending.
Approve immutable batches.
Never blindly retry an uncertain payout.
Make every balance explainable.
That is how a payout API becomes a trusted merchant operations product.
Which niche would you start with: course platforms, affiliate programs, creator marketplaces, agencies, or reseller networks?
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 Telegram Paid Access System with Crypto Payments
- Build a Crypto Payment Reconciliation Tool for Merchants
- Build a Payment Automation Studio for Crypto Merchants
- Build a Merchant Crypto Launch Kit for Agencies
- Build a Crypto Payment Module for SaaS Apps
- Build a Crypto Payment Support Desk
- Build a Cross-Border Crypto Payment Stack for Digital Sellers
References
OxaPay payment infrastructure
- OxaPay Generate Invoice
- OxaPay Payment Information
- OxaPay Payment History
- OxaPay Payment Status Table
- OxaPay Webhook
OxaPay payout infrastructure
- OxaPay Payout Service
- OxaPay Generate Payout
- OxaPay Payout Information
- OxaPay Payout History
- OxaPay Payout Status Table
Top comments (0)