DEV Community

kevin.s
kevin.s

Posted on • Edited on

Build a Crypto Revenue Split and Payout System for Merchants

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

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

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

Or:

Payment: paid
Partner earning: available
Payout: reserved
Provider status: confirming
Enter fullscreen mode Exit fullscreen mode

Or:

Payment: refunded
Partner payout: already confirmed
Partner balance: negative
Operational case: open
Enter fullscreen mode Exit fullscreen mode

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

Net received amount

Customer order value: 100
Provider and network costs: 1
Split basis: 99
Enter fullscreen mode Exit fullscreen mode

Net amount after merchant deductions

Customer order value: 100
Provider costs: 1
Refund reserve: 5
Split basis: 94
Enter fullscreen mode Exit fullscreen mode

There is no universal answer.

The merchant policy must define:

allocation_basis = gross_order_value
Enter fullscreen mode Exit fullscreen mode

or:

allocation_basis = net_received
Enter fullscreen mode Exit fullscreen mode

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

The journal answers:

Where did the value come from?

Who owns the value now?

Which payment created it?

Which rule determined it?
Enter fullscreen mode Exit fullscreen mode

When an instructor payout of 70 USDT is reserved:

Debit  Partner payable: instructor             70 USDT
Credit Payouts in transit: instructor           70 USDT
Enter fullscreen mode Exit fullscreen mode

When the payout is confirmed:

Debit  Payouts in transit: instructor           70 USDT
Credit Merchant funds control                   70 USDT
Enter fullscreen mode Exit fullscreen mode

If the payout is rejected:

Debit  Payouts in transit: instructor           70 USDT
Credit Partner payable: instructor              70 USDT
Enter fullscreen mode Exit fullscreen mode

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

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

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

Authenticate with:

merchant_api_key
Enter fullscreen mode Exit fullscreen mode

Payment Information

Verify one payment by track_id:

GET https://api.oxapay.com/v1/payment/{track_id}
Enter fullscreen mode Exit fullscreen mode

Payment History

Recover and reconcile incoming payments:

GET https://api.oxapay.com/v1/payment
Enter fullscreen mode Exit fullscreen mode

Generate Payout

Create an outbound payout request:

POST https://api.oxapay.com/v1/payout
Enter fullscreen mode Exit fullscreen mode

Authenticate with:

payout_api_key
Enter fullscreen mode Exit fullscreen mode

Payout Information

Retrieve one payout:

GET https://api.oxapay.com/v1/payout/{track_id}
Enter fullscreen mode Exit fullscreen mode

Payout History

Recover and reconcile outbound payouts:

GET https://api.oxapay.com/v1/payout
Enter fullscreen mode Exit fullscreen mode

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()
);
Enter fullscreen mode Exit fullscreen mode

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

Suppose version 4 says:

Platform: 20%
Instructor: 70%
Affiliate: 10%
Enter fullscreen mode Exit fullscreen mode

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()}`,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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"),
  );
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Load the payment by track_id.
  2. Retrieve current Payment Information.
  3. Verify the provider status.
  4. Verify the order mapping.
  5. Verify the amount policy.
  6. Load the frozen split-rule version.
  7. Calculate allocations.
  8. Post one balanced journal transaction.
  9. Create earning-allocation records.
  10. 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;
  });
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

Exception states can include:

reversed
disputed
blocked
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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:

  1. Select available earnings.
  2. Apply minimum thresholds.
  3. Exclude blocked partners.
  4. Load the verified payout-method version.
  5. Lock selected earnings.
  6. Create payout items.
  7. Create reservation records.
  8. Post reservation journal entries.
  9. 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",
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

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

It should mean:

An authorized person approved this exact collection of partners, amounts, assets, networks, and addresses.
Enter fullscreen mode Exit fullscreen mode

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:

  1. Recalculate the hash.
  2. Compare it with the frozen hash.
  3. Reject approval if anything changed.
  4. Store the hash in the approval record.
  5. 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(),
      },
    }),
  ]);
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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

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");
    }
  },
);
Enter fullscreen mode Exit fullscreen mode

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

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"
  );
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Mark the payout item completed.
  2. Post one payout-settlement journal.
  3. Mark reserved allocations paid.
  4. Close the operational instruction.

When a payout becomes rejected or canceled:

  1. Move the item to review or released.
  2. Post one reservation-release journal.
  3. Return associated allocations to available status.
  4. 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,
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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;
  });
}
Enter fullscreen mode Exit fullscreen mode

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

The ledger should record the result explicitly.

Merchant-paid example:

Debit  Payout fee expense
Credit Merchant funds control
Enter fullscreen mode Exit fullscreen mode

Partner-paid example:

Debit  Partner payable
Credit Payout fee recovery
Enter fullscreen mode Exit fullscreen mode

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

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

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

Recovered events should identify their source:

payment_history_backfill

payout_history_backfill

manual_provider_refresh
Enter fullscreen mode Exit fullscreen mode

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

For each payout:

Payout item: PAY-882
Amount: 350 USDT
Network: Tron
Address version: 3
Status: Confirming
Enter fullscreen mode Exit fullscreen mode

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

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

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

Support:

Platform share
Instructor share
Optional affiliate share
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

References

OxaPay payment infrastructure

OxaPay payout infrastructure

OxaPay integrations

Security

Top comments (0)