DEV Community

kevin.s
kevin.s

Posted on

Build a Crypto Revenue Split and Payout System for Merchants

Most payment tutorials stop too early.

They show you how to create an invoice, redirect the buyer, receive a webhook, and mark an order as paid.

That is useful, but it is not where many real merchant problems end.

For marketplaces, creator platforms, affiliate programs, course platforms, agencies, reseller networks, and service communities, the real operational question often comes after the customer pays:

Who should receive the money, how much should each party receive, when should payouts happen, and how do we prove what happened later?

That is the business opportunity.

A developer can build a crypto revenue split and payout system for merchants who already have revenue coming in, but do not want to manage spreadsheets, manual wallet transfers, partner balances, payout approvals, and payment disputes by hand.

In this article, I will use OxaPay as the example payment infrastructure because its documentation exposes the payment and payout primitives required for this kind of system: invoice generation, callback URLs, payment webhooks, payment history, payout creation, payout information, payout history, payout status tracking, SDKs, and automation integrations.

This is not an article about creating a new crypto payment gateway.

It is about building a merchant-facing operational product on top of existing crypto payment infrastructure.

The core idea

A crypto revenue split and payout system helps a merchant collect payment from customers and later distribute revenue to the right people.

The developer does not simply build a checkout button.

The developer builds the layer between incoming payments and outgoing payouts.

That layer usually includes:

  • payment intake
  • order matching
  • partner or seller records
  • revenue split rules
  • internal ledger entries
  • partner balances
  • payout thresholds
  • payout approval flows
  • payout execution
  • payout status tracking
  • audit logs
  • finance exports
  • support visibility

The merchant buys the system because manual revenue sharing becomes painful as soon as transaction volume grows.

A simple example:

A course platform sells a $100 course.

The platform keeps 20%.

The instructor receives 70%.

An affiliate receives 10%.

The payment arrives in crypto.

Without a system, someone has to calculate every share, update spreadsheets, check whether the payment was fully confirmed, copy wallet addresses, send payouts, record transaction IDs, answer partner questions, and fix mistakes.

With a proper system, the flow becomes:

Customer pays invoice
        ↓
Payment webhook confirms paid status
        ↓
System records payment in ledger
        ↓
Split engine calculates shares
        ↓
Partner balances are updated
        ↓
Payout queue is created
        ↓
Admin approves payout batch
        ↓
Payout API sends funds
        ↓
Payout webhook updates final status
        ↓
Partner dashboard and finance export are updated
Enter fullscreen mode Exit fullscreen mode

That is a real product.

Why merchants would pay for this

Merchants do not pay for code because it is interesting.

They pay when a system removes a recurring operational problem.

Revenue split and payout workflows create several recurring problems:

  • partners ask when they will be paid
  • sellers dispute their balances
  • affiliates want transparent reporting
  • finance teams need exportable records
  • payouts get delayed by manual review
  • wallet addresses change
  • a wrong payout can be expensive
  • support teams need to understand payment history
  • one payment can belong to multiple parties
  • one partner can earn from many payments
  • one payout batch can contain many partner payments

The value is not only automation.

The deeper value is control.

A merchant wants to know:

  • which payment created this payable?
  • which rule created this partner share?
  • has this payout been approved?
  • was the payout sent?
  • did the blockchain transaction confirm?
  • was the payout rejected?
  • who changed the partner wallet address?
  • why did the balance change?
  • can finance export this month’s payout report?

That is why this can become a paid product or service.

Who can use this kind of product?

This model works best when a business receives money from customers and must distribute money to other people later.

Good target users include:

Business type Revenue split problem
Marketplaces Split revenue between platform and sellers
Creator platforms Pay creators after customer purchases
Course platforms Pay instructors and affiliates
Agencies Pay contractors, referrers, and partners
Affiliate programs Track commission and batch payouts
Freelancer networks Pay service providers after customer payment
Gaming communities Share revenue with server owners, teams, or partners
Donation platforms Route donations to campaigns, creators, or beneficiaries
Reseller programs Calculate reseller commission and payout balances
SaaS partner programs Pay referral or integration partners

The best customers are not tiny merchants with one transaction per week.

The best customers are merchants with recurring transaction flow, multiple partners, and enough operational complexity that spreadsheets start breaking.

The important distinction: split logic is yours, payment infrastructure is OxaPay

This is the first design rule.

OxaPay can help you create invoices, receive webhooks, retrieve payment records, and generate payout requests.

Your application must own the revenue split logic.

That means your system decides:

  • who receives a share
  • how much each partner receives
  • which payment created the balance
  • whether funds should be held
  • whether the payout requires approval
  • whether there is a minimum payout threshold
  • whether a partner is allowed to receive payouts
  • whether a payout should be delayed, blocked, or retried

This distinction matters.

Do not build a system that receives a payment webhook and immediately sends payouts in the same request.

That is fragile.

A production-grade system should use this pattern:

Payment confirmation → ledger entry → split calculation → payout queue → approval → payout execution
Enter fullscreen mode Exit fullscreen mode

The ledger is the source of truth.

The payout API is the execution layer.

OxaPay primitives used in this system

For this article, we need several OxaPay primitives.

1. Generate Invoice

OxaPay's Generate Invoice endpoint lets a merchant create a new invoice and obtain a payment URL. The request supports fields such as amount, currency, lifetime, fee_paid_by_payer, under_paid_coverage, callback_url, return_url, and order_id.

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

In a revenue split system, the invoice represents the customer-facing payment request.

The order_id should reference your internal order, booking, sale, or transaction record.

The callback_url should point to your webhook endpoint.

2. Webhook

OxaPay sends payment status updates to the callback_url. According to the webhook documentation, callbacks are sent as JSON over HTTP POST. The system expects your endpoint to return HTTP 200 with ok, and OxaPay retries webhook delivery up to five times if delivery fails.

Docs: https://docs.oxapay.com/webhook

This is important because revenue split logic should only start after the payment reaches the correct confirmed business state.

In most systems, you should not create partner balances from early or incomplete payment states.

3. HMAC validation

OxaPay webhooks include an HMAC header. The docs state that merchants should validate callbacks using the merchant API key for payment webhooks, and the payout API key for payout webhooks. The signature is generated over the raw POST body using HMAC SHA-512.

Docs: https://docs.oxapay.com/webhook

This is not optional in a payout-related system.

If your application updates balances or payout status from webhooks, signature validation is a core security requirement.

4. Payment History

The Payment History endpoint retrieves payments associated with a merchant account. It supports filters such as track_id, type, status, pay_currency, currency, network, address, date range, amount range, sorting, and pagination.

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

This is useful for reconciliation jobs and admin dashboards.

If a webhook is delayed or your processing job fails, your system can use payment history or payment information to reconcile its internal state.

5. Generate Payout

OxaPay's Generate Payout endpoint creates a cryptocurrency payout request to a specified recipient address. The request uses a payout_api_key and fields such as address, currency, amount, network, callback_url, memo, and description.

Docs: https://docs.oxapay.com/api-reference/payout/generate-payout

This is the execution step after your own system has calculated and approved a payout.

6. Payout Information and Payout History

Payout Information retrieves the details of a specific payout using its track_id. Payout History retrieves payout transactions associated with the account and supports filtering by status, type, currency, network, amount range, date range, sorting, and pagination.

Docs:

These endpoints are useful for payout dashboards, reconciliation, and support tools.

7. Payout Status Table

The payout status table lists states such as processing, pending, confirming, confirmed, canceled, and rejected.

Docs: https://docs.oxapay.com/api-reference/payout/payout-status-table

Your system should map these external statuses to internal payout states.

8. SDKs and automation tools

OxaPay also provides SDKs and automation integrations. The PHP, Python, and Laravel SDK pages include payment methods and webhook handling notes. The Make integration lists modules such as Generate Invoice, Generate Payout, Get Payment Information, Get Payout Information, Search Payments, Search Payouts, Watch Payment Webhook, and Watch Payout Webhook.

Docs:

This gives developers multiple implementation paths: code-first, SDK-based, or workflow-assisted.

What you are actually building

A real revenue split and payout product has five layers.

Layer 1: Payment intake

This layer creates invoices and receives payment webhooks.

It should handle:

  • order creation
  • invoice generation
  • customer redirect
  • payment callback
  • payment status updates
  • duplicate webhook events
  • late payment events
  • failed or incomplete payment states

Layer 2: Ledger

This is the most important layer.

The ledger records financial movements inside your application.

A common mistake is to only store balances as a single number.

That is not enough.

You need immutable ledger entries.

A partner balance should be calculated from ledger entries, not manually edited as a random number.

Layer 3: Split engine

The split engine decides how revenue is allocated.

It may support:

  • fixed percentage splits
  • product-owner splits
  • affiliate commission
  • platform fees
  • instructor revenue share
  • creator revenue share
  • multi-party splits
  • reserves or holdbacks
  • minimum payout thresholds
  • tiered commission rules

Layer 4: Payout queue

Do not execute payouts immediately.

Create payout queue items first.

A payout queue allows:

  • review before sending
  • minimum threshold checks
  • partner address verification
  • compliance review where needed
  • balance checks
  • scheduled payouts
  • batch approvals
  • retries
  • status tracking

Layer 5: Payout execution and tracking

After approval, the system sends payout requests through the payout API.

Then it listens for payout webhooks or checks payout information/history to update payout state.

The goal is not only to send funds.

The goal is to make payouts traceable.

Reference architecture

A practical architecture can look like this:

Customer checkout
      ↓
Create order in merchant app
      ↓
Create OxaPay invoice
      ↓
Customer pays
      ↓
OxaPay payment webhook
      ↓
Validate HMAC
      ↓
Store raw webhook event
      ↓
Process payment idempotently
      ↓
Create ledger entries
      ↓
Apply split rules
      ↓
Update partner balances
      ↓
Create payout queue items
      ↓
Admin reviews payout batch
      ↓
Call OxaPay Generate Payout
      ↓
OxaPay payout webhook
      ↓
Validate payout HMAC
      ↓
Update payout item status
      ↓
Update partner dashboard and finance exports
Enter fullscreen mode Exit fullscreen mode

That is the architecture a merchant is paying for.

The database model

You do not need an over-engineered database on day one.

But you do need the right concepts.

Here is a simplified schema.

CREATE TABLE merchants (
  id UUID PRIMARY KEY,
  name TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'active',
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE partners (
  id UUID PRIMARY KEY,
  merchant_id UUID NOT NULL REFERENCES merchants(id),
  name TEXT NOT NULL,
  email TEXT,
  status TEXT NOT NULL DEFAULT 'active',
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE partner_payout_methods (
  id UUID PRIMARY KEY,
  partner_id UUID NOT NULL REFERENCES partners(id),
  currency TEXT NOT NULL,
  network TEXT,
  address TEXT NOT NULL,
  memo TEXT,
  status TEXT NOT NULL DEFAULT 'pending_verification',
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  verified_at TIMESTAMPTZ
);

CREATE TABLE orders (
  id UUID PRIMARY KEY,
  merchant_id UUID NOT NULL REFERENCES merchants(id),
  external_order_id TEXT NOT NULL,
  amount_decimal NUMERIC(36, 18) NOT NULL,
  currency TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'created',
  created_at TIMESTAMPTZ 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 UNIQUE,
  status TEXT NOT NULL,
  amount_decimal NUMERIC(36, 18),
  currency TEXT,
  network TEXT,
  raw_payload JSONB,
  paid_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE split_rules (
  id UUID PRIMARY KEY,
  merchant_id UUID NOT NULL REFERENCES merchants(id),
  name TEXT NOT NULL,
  rule_type TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'active',
  config JSONB NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE ledger_entries (
  id UUID PRIMARY KEY,
  merchant_id UUID NOT NULL REFERENCES merchants(id),
  partner_id UUID REFERENCES partners(id),
  payment_id UUID REFERENCES payments(id),
  entry_type TEXT NOT NULL,
  amount_decimal NUMERIC(36, 18) NOT NULL,
  currency TEXT NOT NULL,
  direction TEXT NOT NULL CHECK (direction IN ('credit', 'debit')),
  description TEXT,
  metadata JSONB,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE payout_batches (
  id UUID PRIMARY KEY,
  merchant_id UUID NOT NULL REFERENCES merchants(id),
  status TEXT NOT NULL DEFAULT 'draft',
  created_by UUID,
  approved_by UUID,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  approved_at TIMESTAMPTZ
);

CREATE TABLE payout_items (
  id UUID PRIMARY KEY,
  batch_id UUID REFERENCES payout_batches(id),
  merchant_id UUID NOT NULL REFERENCES merchants(id),
  partner_id UUID NOT NULL REFERENCES partners(id),
  payout_method_id UUID NOT NULL REFERENCES partner_payout_methods(id),
  amount_decimal NUMERIC(36, 18) NOT NULL,
  currency TEXT NOT NULL,
  network TEXT,
  status TEXT NOT NULL DEFAULT 'queued',
  provider TEXT DEFAULT 'oxapay',
  provider_track_id TEXT UNIQUE,
  provider_status TEXT,
  error_message TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  sent_at TIMESTAMPTZ,
  confirmed_at TIMESTAMPTZ
);

CREATE TABLE webhook_events (
  id UUID PRIMARY KEY,
  provider TEXT NOT NULL,
  event_type TEXT NOT NULL,
  provider_track_id TEXT,
  signature_valid BOOLEAN NOT NULL,
  processed BOOLEAN NOT NULL DEFAULT false,
  raw_payload JSONB NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (provider, event_type, provider_track_id, raw_payload)
);

CREATE TABLE audit_logs (
  id UUID PRIMARY KEY,
  merchant_id UUID NOT NULL REFERENCES merchants(id),
  actor_id UUID,
  action TEXT NOT NULL,
  entity_type TEXT NOT NULL,
  entity_id UUID,
  metadata JSONB,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Enter fullscreen mode Exit fullscreen mode

This schema is not final.

But it captures the key idea:

  • payments are not payouts
  • balances come from ledger entries
  • payout items are queued before execution
  • webhook events are stored
  • partner payout methods are separate records
  • audit logs exist for sensitive actions

Why a ledger matters

Imagine this partner balance:

Partner A balance: 700 USDT
Enter fullscreen mode Exit fullscreen mode

That number alone tells you almost nothing.

A real system needs to explain why the balance is 700 USDT.

A ledger can show:

+70 USDT  Course sale #1001 instructor share
+70 USDT  Course sale #1002 instructor share
+70 USDT  Course sale #1003 instructor share
-100 USDT Payout batch #22
+20 USDT  Affiliate bonus correction
Enter fullscreen mode Exit fullscreen mode

The balance is the result of events.

This gives your merchant trust.

It also gives partners visibility.

Without a ledger, every balance dispute becomes a manual investigation.

Split rule examples

A revenue split system should support simple rules first.

Fixed percentage split

{
  "platform_fee_percent": 20,
  "partner_percent": 80
}
Enter fullscreen mode Exit fullscreen mode

Use case:

A marketplace keeps 20%, seller receives 80%.

Instructor plus affiliate split

{
  "platform_fee_percent": 20,
  "instructor_percent": 70,
  "affiliate_percent": 10
}
Enter fullscreen mode Exit fullscreen mode

Use case:

An online course platform pays the instructor and affiliate after a sale.

Product owner split

{
  "platform_fee_percent": 15,
  "product_owner_percent": 85
}
Enter fullscreen mode Exit fullscreen mode

Use case:

A digital product marketplace pays the product creator.

Holdback or reserve

{
  "platform_fee_percent": 20,
  "partner_percent": 70,
  "reserve_percent": 10,
  "reserve_release_days": 14
}
Enter fullscreen mode Exit fullscreen mode

Use case:

A merchant wants to delay part of partner earnings for support issues, refunds, delivery disputes, or risk management.

Even when crypto payments are operationally different from card payments, merchants may still need internal holds for customer support, refund policies, service delivery windows, or partner disputes.

Example: create an OxaPay invoice

Here is a simplified Node.js example.

import fetch from "node-fetch";

const OXAPAY_MERCHANT_API_KEY = process.env.OXAPAY_MERCHANT_API_KEY;

export async function createInvoiceForOrder(order) {
  const response = await fetch("https://api.oxapay.com/v1/payment/invoice", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "merchant_api_key": OXAPAY_MERCHANT_API_KEY
    },
    body: JSON.stringify({
      amount: order.amount,
      currency: order.currency,
      order_id: order.id,
      lifetime: 60,
      callback_url: "https://yourapp.com/webhooks/oxapay/payment",
      return_url: `https://yourapp.com/orders/${order.id}/thank-you`,
      description: `Order ${order.id}`
    })
  });

  if (!response.ok) {
    throw new Error(`OxaPay invoice request failed: ${response.status}`);
  }

  const result = await response.json();

  return result;
}
Enter fullscreen mode Exit fullscreen mode

In your database, store:

  • internal order ID
  • OxaPay track ID if returned
  • payment URL if returned
  • requested amount
  • requested currency
  • invoice status
  • creation time
  • expiration time

Do not rely only on the frontend redirect.

The webhook should be the source of payment confirmation.

Example: validate and store a payment webhook

Webhook handling should be fast, verifiable, and idempotent.

Do not perform heavy split calculations before responding.

Store the event, return ok, and process it asynchronously.

import express from "express";
import crypto from "crypto";

const app = express();

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

    if (!receivedHmac) {
      return res.status(400).send("missing hmac");
    }

    const isValid = verifyHmac(
      rawBody,
      receivedHmac,
      process.env.OXAPAY_MERCHANT_API_KEY
    );

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

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

    // Store the raw event first.
    const event = await db.webhook_events.insert({
      provider: "oxapay",
      event_type: "payment",
      provider_track_id: String(payload.track_id || ""),
      signature_valid: true,
      raw_payload: payload
    });

    // Return exactly what the provider expects.
    res.status(200).send("ok");

    // Process asynchronously.
    await queue.add("process_oxapay_payment_webhook", {
      webhook_event_id: event.id
    });
  }
);

function verifyHmac(rawBody, receivedHmac, secret) {
  const expected = crypto
    .createHmac("sha512", secret)
    .update(rawBody)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(receivedHmac, "hex");

  if (a.length !== b.length) return false;

  return crypto.timingSafeEqual(a, b);
}
Enter fullscreen mode Exit fullscreen mode

OxaPay documentation says payment callbacks should be validated using the merchant API key, and payout callbacks should be validated using the payout API key.

So use different secrets for different webhook routes.

Processing the payment webhook idempotently

A webhook can be retried.

Your system must survive duplicates.

This is the minimum logic:

async function processPaymentWebhook(eventId) {
  const event = await db.webhook_events.findById(eventId);
  const payload = event.raw_payload;

  await db.transaction(async (trx) => {
    const existingPayment = await trx.payments.findOne({
      provider: "oxapay",
      provider_track_id: String(payload.track_id)
    });

    if (existingPayment?.status === "paid") {
      await trx.webhook_events.markProcessed(eventId);
      return;
    }

    const payment = await trx.payments.upsertByProviderTrackId({
      provider: "oxapay",
      provider_track_id: String(payload.track_id),
      status: normalizeOxaPayPaymentStatus(payload.status),
      amount_decimal: payload.amount,
      currency: payload.currency,
      network: payload.network,
      raw_payload: payload
    });

    if (payment.status !== "paid") {
      await trx.webhook_events.markProcessed(eventId);
      return;
    }

    const order = await trx.orders.findByProviderPayment(payment.id);

    if (!order) {
      await trx.webhook_events.markProcessed(eventId);
      await trx.audit_logs.insert({
        merchant_id: payment.merchant_id,
        action: "payment_without_order",
        entity_type: "payment",
        entity_id: payment.id,
        metadata: { provider_track_id: payment.provider_track_id }
      });
      return;
    }

    if (order.status === "paid") {
      await trx.webhook_events.markProcessed(eventId);
      return;
    }

    await trx.orders.update(order.id, { status: "paid" });

    await applySplitRules(trx, {
      merchant_id: order.merchant_id,
      order_id: order.id,
      payment_id: payment.id,
      amount: order.amount_decimal,
      currency: order.currency
    });

    await trx.webhook_events.markProcessed(eventId);
  });
}

function normalizeOxaPayPaymentStatus(status) {
  if (!status) return "unknown";
  return String(status).toLowerCase();
}
Enter fullscreen mode Exit fullscreen mode

The exact payload fields should be mapped from your tested webhook payloads and the Payment Information response.

The important part is the process:

  • verify signature
  • store raw event
  • deduplicate by provider track ID
  • process inside a database transaction
  • only apply split rules once
  • mark the webhook as processed

Applying split rules

For financial calculations, do not use floating point arithmetic.

Use integer minor units where possible or a decimal library with explicit rounding rules.

For crypto amounts, use a decimal type that can preserve precision.

Here is a simplified split example using decimal logic conceptually.

import Decimal from "decimal.js";

async function applySplitRules(trx, input) {
  const { merchant_id, order_id, payment_id, amount, currency } = input;

  const order = await trx.orders.findById(order_id);
  const rule = await trx.split_rules.findActiveForOrder(order);

  const total = new Decimal(amount);

  const platformFee = total.mul(rule.config.platform_fee_percent).div(100);
  const instructorShare = total.mul(rule.config.instructor_percent).div(100);
  const affiliateShare = total.mul(rule.config.affiliate_percent || 0).div(100);

  // Optional: keep rounding dust in the platform account.
  const allocated = platformFee.plus(instructorShare).plus(affiliateShare);
  const roundingRemainder = total.minus(allocated);

  await trx.ledger_entries.insertMany([
    {
      merchant_id,
      payment_id,
      partner_id: null,
      entry_type: "platform_fee",
      direction: "credit",
      amount_decimal: platformFee.plus(roundingRemainder).toString(),
      currency,
      description: `Platform fee for order ${order.external_order_id}`
    },
    {
      merchant_id,
      payment_id,
      partner_id: order.instructor_partner_id,
      entry_type: "partner_share",
      direction: "credit",
      amount_decimal: instructorShare.toString(),
      currency,
      description: `Instructor share for order ${order.external_order_id}`
    }
  ]);

  if (affiliateShare.gt(0) && order.affiliate_partner_id) {
    await trx.ledger_entries.insert({
      merchant_id,
      payment_id,
      partner_id: order.affiliate_partner_id,
      entry_type: "affiliate_commission",
      direction: "credit",
      amount_decimal: affiliateShare.toString(),
      currency,
      description: `Affiliate commission for order ${order.external_order_id}`
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

In production, you need more controls:

  • rule versioning
  • effective dates
  • product-specific rules
  • partner-specific overrides
  • minimum payout thresholds
  • maximum payout limits
  • rounding policy
  • reserve policy
  • manual adjustments
  • approval workflow for corrections

Never silently edit past ledger entries.

If you need to fix something, create a reversing entry or adjustment entry.

That is how you preserve auditability.

Building the payout queue

A payout queue is where earned balances become payable items.

A daily or weekly job can calculate partner balances and create payout items.

async function createPayoutBatchForMerchant(merchantId) {
  await db.transaction(async (trx) => {
    const batch = await trx.payout_batches.insert({
      merchant_id: merchantId,
      status: "draft"
    });

    const partners = await trx.partners.findActiveWithBalances({
      merchant_id: merchantId,
      min_balance: "25.00"
    });

    for (const partner of partners) {
      const method = await trx.partner_payout_methods.findVerifiedDefault(partner.id);

      if (!method) {
        await trx.audit_logs.insert({
          merchant_id: merchantId,
          action: "payout_skipped_no_verified_method",
          entity_type: "partner",
          entity_id: partner.id,
          metadata: { balance: partner.balance }
        });
        continue;
      }

      await trx.payout_items.insert({
        batch_id: batch.id,
        merchant_id: merchantId,
        partner_id: partner.id,
        payout_method_id: method.id,
        amount_decimal: partner.balance,
        currency: method.currency,
        network: method.network,
        status: "queued"
      });
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

This job should not send funds.

It should create a draft batch.

A merchant admin should review it first, especially in early versions of the product.

Approval workflow

Payouts are sensitive.

Your first version should include manual approval.

At minimum:

  • payout batch created as draft
  • admin reviews partner, amount, currency, network, address
  • admin approves batch
  • system records approved_by and approved_at
  • execution worker sends payout requests
  • payout status is tracked asynchronously

For larger merchants, add stronger controls:

  • two-person approval
  • payout limits per day
  • payout limits per partner
  • address change cooldown
  • require re-approval after address changes
  • role-based permissions
  • audit logs for every approval
  • blocked partner status
  • suspicious payout review queue

This is where your product becomes serious.

Many developers can call a payout API.

Fewer developers build a safe payout approval system.

Example: generate an OxaPay payout

After approval, call the payout API from a backend worker.

Never call payout execution from the frontend.

Never expose the payout API key to client-side code.

import fetch from "node-fetch";

const OXAPAY_PAYOUT_API_KEY = process.env.OXAPAY_PAYOUT_API_KEY;

export async function sendOxaPayPayout(payoutItem) {
  const response = await fetch("https://api.oxapay.com/v1/payout", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "payout_api_key": OXAPAY_PAYOUT_API_KEY
    },
    body: JSON.stringify({
      address: payoutItem.address,
      amount: payoutItem.amount_decimal,
      currency: payoutItem.currency,
      network: payoutItem.network,
      memo: payoutItem.memo || undefined,
      callback_url: "https://yourapp.com/webhooks/oxapay/payout",
      description: `Payout item ${payoutItem.id}`
    })
  });

  const result = await response.json();

  if (!response.ok || result.status >= 400) {
    throw new Error(`OxaPay payout failed: ${JSON.stringify(result.error || result)}`);
  }

  return result;
}
Enter fullscreen mode Exit fullscreen mode

After sending the request, store:

  • payout item ID
  • provider track ID
  • request payload hash
  • requested amount
  • requested currency
  • network
  • payout address reference
  • provider status
  • sent timestamp

Do not delete the payout item if the provider returns an error.

Record the failure and require review.

Payout state machine

Your internal payout state machine should not blindly mirror provider statuses.

Use your own states and map provider statuses into them.

Example:

Internal status Meaning Possible OxaPay mapping
queued Created but not approved Internal only
approved Approved by merchant admin Internal only
sending Worker is calling payout API Internal only
provider_processing Provider accepted request processing
provider_pending Request is in provider queue pending
blockchain_confirming Transaction created and awaiting confirmation confirming
completed Payout completed successfully confirmed
failed Payout failed or rejected rejected
canceled Payout was canceled canceled
needs_review Human review needed Internal only

This separation makes your system easier to evolve.

If OxaPay changes wording or returns additional states later, you only update the mapping layer.

Example: payout webhook handler

Use a separate route and validate with the payout API key.

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

    if (!receivedHmac) {
      return res.status(400).send("missing hmac");
    }

    const isValid = verifyHmac(
      rawBody,
      receivedHmac,
      process.env.OXAPAY_PAYOUT_API_KEY
    );

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

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

    const event = await db.webhook_events.insert({
      provider: "oxapay",
      event_type: "payout",
      provider_track_id: String(payload.track_id || ""),
      signature_valid: true,
      raw_payload: payload
    });

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

    await queue.add("process_oxapay_payout_webhook", {
      webhook_event_id: event.id
    });
  }
);
Enter fullscreen mode Exit fullscreen mode

Then process it:

async function processPayoutWebhook(eventId) {
  const event = await db.webhook_events.findById(eventId);
  const payload = event.raw_payload;

  await db.transaction(async (trx) => {
    const payoutItem = await trx.payout_items.findByProviderTrackId(
      String(payload.track_id)
    );

    if (!payoutItem) {
      await trx.audit_logs.insert({
        merchant_id: null,
        action: "unknown_payout_webhook",
        entity_type: "webhook_event",
        entity_id: eventId,
        metadata: payload
      });
      return;
    }

    const nextStatus = mapOxaPayPayoutStatus(payload.status);

    await trx.payout_items.update(payoutItem.id, {
      provider_status: payload.status,
      status: nextStatus,
      confirmed_at: nextStatus === "completed" ? new Date() : payoutItem.confirmed_at
    });

    if (nextStatus === "completed") {
      await trx.ledger_entries.insert({
        merchant_id: payoutItem.merchant_id,
        partner_id: payoutItem.partner_id,
        entry_type: "payout_debit",
        direction: "debit",
        amount_decimal: payoutItem.amount_decimal,
        currency: payoutItem.currency,
        description: `Payout completed for item ${payoutItem.id}`,
        metadata: {
          payout_item_id: payoutItem.id,
          provider_track_id: payoutItem.provider_track_id
        }
      });
    }

    if (["failed", "canceled", "needs_review"].includes(nextStatus)) {
      await trx.audit_logs.insert({
        merchant_id: payoutItem.merchant_id,
        action: "payout_requires_review",
        entity_type: "payout_item",
        entity_id: payoutItem.id,
        metadata: payload
      });
    }
  });
}

function mapOxaPayPayoutStatus(status) {
  switch (String(status).toLowerCase()) {
    case "processing":
      return "provider_processing";
    case "pending":
      return "provider_pending";
    case "confirming":
      return "blockchain_confirming";
    case "confirmed":
      return "completed";
    case "rejected":
      return "failed";
    case "canceled":
      return "canceled";
    default:
      return "needs_review";
  }
}
Enter fullscreen mode Exit fullscreen mode

In a real implementation, make sure the debit ledger entry is also idempotent.

A duplicate payout webhook should not create two payout debits.

Use a unique constraint such as:

CREATE UNIQUE INDEX unique_payout_debit_entry
ON ledger_entries ((metadata->>'payout_item_id'))
WHERE entry_type = 'payout_debit';
Enter fullscreen mode Exit fullscreen mode

Idempotency is not optional

Payment and payout systems are distributed systems.

Requests fail.

Webhooks retry.

Workers crash.

Admins double-click buttons.

Networks timeout.

If your payout system is not idempotent, it can duplicate financial actions.

Use idempotency at several levels:

  • unique internal order IDs
  • unique payment provider track IDs
  • unique webhook event records
  • unique payout item IDs
  • unique provider payout track IDs
  • unique ledger debit per payout item
  • database transactions around state transitions
  • worker locks when executing payout batches

Stripe's public API docs explain the general idea well: an idempotency key lets a server recognize retries of the same operation and return the original result instead of creating a duplicate operation.

Reference: https://docs.stripe.com/api/idempotent_requests

Even if OxaPay's specific endpoints do not expose a public idempotency-key parameter in the examples, your own application should still implement internal idempotency around all payment and payout state transitions.

Security model

A payout system has a larger blast radius than a checkout integration.

A checkout bug may fail to activate an order.

A payout bug may send funds to the wrong address.

That changes the engineering standard.

Protect API keys

Use separate secrets for merchant payment flows and payout flows.

Store them in a secret manager or encrypted environment configuration.

Never expose them to browser code.

Limit who can access payout configuration.

Verify payout addresses

Partner payout addresses should not go from form input to payout execution immediately.

Use a verification workflow:

  • partner submits address
  • system marks it pending_verification
  • merchant admin reviews it
  • optional confirmation email is sent
  • address becomes verified
  • address changes trigger cooldown or re-approval

Use role-based access control

A partner should only see their own earnings.

An admin should only manage their own merchant account.

A support agent should not be able to approve payouts.

OWASP lists Broken Object Level Authorization as a major API security risk. In a payout product, this is especially important because object IDs often represent sensitive resources such as partner balances, payout methods, payout items, or merchant records.

Reference: https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/

Add payout limits

Start with conservative limits:

  • max payout per item
  • max payout per partner per day
  • max payout per merchant per day
  • max payout batch size
  • minimum payout threshold
  • cooldown after payout address change

These controls protect both the merchant and your product.

Keep audit logs

Every sensitive action should produce an audit log:

  • split rule created
  • split rule changed
  • partner payout address added
  • payout address verified
  • payout batch created
  • payout batch approved
  • payout sent
  • payout canceled
  • manual adjustment created

When money moves, logs are not optional.

Reconciliation jobs

Webhooks are useful, but a production payout product should also reconcile.

Schedule jobs that compare your internal records against provider records.

Example jobs:

Every 15 minutes:
- find payout items in provider_processing, provider_pending, blockchain_confirming
- call Payout Information by provider_track_id
- update stale statuses

Every day:
- retrieve Payout History for the previous day
- compare provider payouts with internal payout items
- flag missing or unknown payouts

Every day:
- retrieve Payment History for the previous day
- compare paid provider payments with internal paid orders
- flag mismatches
Enter fullscreen mode Exit fullscreen mode

This matters because webhooks can fail, infrastructure can be down, and humans can make mistakes.

OxaPay's Payment History and Payout History endpoints are useful for this kind of reconciliation because they allow filtered and paginated retrieval of payment and payout records.

Handling edge cases

A good payout product is built around edge cases.

Duplicate payment webhook

Process the payment once.

Use provider track ID and internal payment status checks.

Payment is paying but not paid

Do not create final partner balances yet.

Wait for the paid state required by your business logic.

Invoice expired but customer claims payment

Use Payment Information or Payment History to investigate.

Give support a payment timeline.

Split percentages do not add to 100%

Reject the rule or assign the remainder to a defined platform rounding account.

Never let the system silently lose money.

Partner address changed before payout

Require re-approval.

Consider a cooldown window.

Payout rejected

Move the payout item to needs_review.

Do not automatically retry forever.

Currency mismatch

Define whether each partner is paid in the same currency as the incoming payment, a preferred payout currency, or a merchant-selected settlement currency.

Document the rule clearly.

Insufficient balance

Keep payout item queued or failed with a clear reason.

Notify the merchant admin.

Rounding dust

Use a consistent policy.

Usually the safest option is to allocate rounding remainder to the platform account or a specific rounding ledger account.

Manual adjustment

Never edit the original ledger entry.

Create a new adjustment entry.

MVP version

The MVP should be intentionally narrow.

Do not start with every marketplace feature.

Build this first:

  • one merchant account
  • one incoming payment flow
  • one currency
  • one network
  • fixed percentage split rules
  • partner records
  • verified payout addresses
  • immutable ledger entries
  • manual payout batch creation
  • manual payout approval
  • OxaPay payout execution
  • payout webhook handling
  • partner balance dashboard
  • CSV export
  • audit logs for payout actions

This is enough to sell to a small marketplace, course platform, agency, or creator platform.

The MVP should prove that merchants trust the numbers.

Not that your UI has every feature.

Production version

A production version can add:

  • multi-merchant support
  • multi-currency support
  • multi-network payout methods
  • advanced split rules
  • rule versioning
  • reserves and release schedules
  • partner self-service portal
  • payout batch scheduling
  • two-person approval
  • balance reconciliation
  • accounting exports
  • webhook replay tools
  • API for merchant platforms
  • white-label dashboard for agencies
  • notification system
  • dispute notes
  • operational alerts
  • role-based permissions
  • address change cooldown
  • payout risk scoring

This is where the service becomes more than a dev project.

It becomes financial operations software.

Revenue model for developers

There are several ways to monetize this product.

1. Setup fee

Charge for implementation, configuration, and merchant onboarding.

This works well for agencies, course platforms, and marketplaces that need custom integration.

2. Monthly platform fee

Charge a recurring fee for hosting, dashboard access, webhook monitoring, payout queue management, and reporting.

This is better than one-off integration work because the merchant depends on the system every month.

3. Managed payout operations

Some merchants may want you to operate the payout workflow for them.

That can include reviewing payout batches, generating reports, monitoring failed payouts, and coordinating support.

Be careful with responsibility boundaries here.

You should define exactly what you do and what the merchant approves.

4. Usage-based fee

Charge based on:

  • number of partners
  • number of payout items
  • number of payout batches
  • number of monthly paid orders
  • number of supported merchant accounts

5. White-label license

Sell the system to agencies or platforms that want to offer crypto revenue split tooling under their own brand.

This is harder to build, but potentially stronger if you can reach agencies with existing merchant relationships.

Pricing examples

These are not guaranteed income numbers.

They are pricing structures a developer could test.

Package What it includes Possible pricing model
Starter setup One merchant, one split rule, manual payout queue one-time setup fee
Growth setup Multiple partners, dashboard, CSV export, payout tracking higher setup fee + monthly support
Managed ops Monitoring, reconciliation, payout issue review monthly retainer
SaaS dashboard Hosted partner balances and payout queue monthly subscription
Agency license White-label version for agency clients license + support fee
Enterprise custom Custom rules, approval workflows, reporting, integrations project fee + recurring maintenance

If you are starting alone, the easiest first offer is:

I build a crypto revenue split and payout dashboard for your marketplace, course platform, affiliate program, or creator business so you can calculate partner balances and send payouts without spreadsheets.

That is more concrete than:

I integrate crypto payouts.

What to sell first

Do not sell the full platform first.

Sell a narrow outcome.

For example:

For a course platform

Automatically split every crypto course sale between the platform, instructor, and affiliate, then generate weekly payout batches with partner balance reports.

For an agency

Track revenue from client payments, calculate contractor shares, and create monthly crypto payout batches with approval logs.

For a creator marketplace

Let creators see their earned balance and receive approved crypto payouts after customer payments are confirmed.

For an affiliate program

Calculate affiliate commission from paid crypto invoices and create payout queues after a minimum threshold is reached.

The more specific the outcome, the easier it is to sell.

Technical roadmap: 21-day build plan

Days 1-3: Define the niche

Pick one use case:

  • course platform
  • affiliate program
  • small marketplace
  • agency contractor payouts
  • creator platform

Define one split rule.

Do not build every split model yet.

Days 4-6: Build payment intake

  • create merchant/order model
  • create OxaPay invoice
  • store invoice response
  • add payment webhook endpoint
  • validate HMAC
  • store raw webhook events
  • process paid events idempotently

Days 7-9: Build ledger and split engine

  • create ledger schema
  • create one split rule type
  • apply split after paid payment
  • calculate partner balances from ledger entries
  • add tests for duplicate webhooks

Days 10-12: Build partner and payout method management

  • add partner records
  • add payout address records
  • add admin verification flow
  • add audit logs for address changes
  • show partner balances

Days 13-15: Build payout queue

  • create payout batches
  • add minimum payout threshold
  • add manual approval
  • create payout items
  • show admin review screen

Days 16-18: Execute payouts

  • call OxaPay Generate Payout
  • store provider track ID
  • add payout webhook endpoint
  • validate payout HMAC
  • update payout status
  • create payout debit ledger entries idempotently

Days 19-21: Make it sellable

  • add CSV export
  • add partner dashboard
  • add merchant dashboard
  • add failed payout review screen
  • add audit log view
  • add a demo dataset
  • create a landing page
  • record a demo video

At the end of 21 days, you should not have a perfect product.

You should have a sellable prototype.

Testing checklist

Before showing this to a merchant, test these cases:

  • invoice created successfully
  • payment webhook validates HMAC
  • invalid webhook signature is rejected
  • duplicate payment webhook does not duplicate ledger entries
  • payment status before paid does not create partner balances
  • paid payment creates correct ledger entries
  • split rule percentages are validated
  • rounding is handled consistently
  • partner balance is calculated from ledger entries
  • payout address must be verified before use
  • payout batch requires approval
  • payout API key is never exposed to frontend
  • payout webhook validates with payout API key
  • duplicate payout webhook does not duplicate debit entries
  • rejected payout moves to review state
  • canceled payout moves to review state
  • payout history reconciliation detects missing records
  • admin actions create audit logs
  • partner cannot access another partner's balance
  • support can trace payment → split → payout

This checklist is part of the product.

A merchant buying payout infrastructure wants reliability, not just features.

Mistakes to avoid

Mistake 1: Sending payout directly from the payment webhook

This is the biggest mistake.

Payment confirmation and payout execution should be separated by a ledger and payout queue.

Mistake 2: No audit trail

If you cannot explain why a partner balance changed, the merchant will not trust the system.

Mistake 3: Editable balances

Balances should be derived from ledger entries.

Manual correction should be an adjustment entry, not a direct balance edit.

Mistake 4: No approval workflow

Automated payouts sound nice, but most merchants need review controls first.

Mistake 5: Weak authorization

A partner viewing another partner's balance is a serious security issue.

Mistake 6: Ignoring payout address changes

Changing a payout address should be treated as a sensitive event.

Mistake 7: No reconciliation

Webhooks are not a full accounting system.

Use provider history endpoints to detect mismatches.

Mistake 8: Overbuilding before selling

Start with one niche and one split model.

Do not build a general payout platform before validating demand.

Legal, tax, and compliance boundaries

This article is a technical and product-building guide, not legal, tax, or financial advice.

Revenue split and payout systems can touch sensitive obligations depending on jurisdiction, business type, custody model, merchant-of-record structure, tax reporting, sanctions rules, partner onboarding, and the assets involved.

A developer should not casually promise that the software solves compliance.

A safer positioning is:

This product helps merchants calculate, queue, approve, execute, and track crypto payouts using their own payment infrastructure and business rules.

Do not position yourself as the legal owner of funds unless you are prepared for that responsibility.

For many developers, the safer business model is implementation, software, dashboard, automation, and operational tooling for the merchant, where the merchant owns the commercial relationship and payout policy.

Landing page positioning

A weak landing page headline:

Crypto payout API integration.

A stronger headline:

Split crypto revenue between sellers, creators, affiliates, or contractors with a ledger, payout queue, approval flow, and payout tracking.

A simple landing page should include:

  • target niche
  • revenue split problem
  • payment-to-payout flow diagram
  • dashboard screenshots
  • example split rules
  • payout approval workflow
  • audit log example
  • security controls
  • OxaPay infrastructure used
  • pricing packages
  • demo video
  • implementation timeline

The visitor should think:

This developer understands payout operations.

Not:

This developer can call an API.

Final takeaway

A crypto revenue split and payout system is one of the stronger developer business ideas because the merchant problem is painful, recurring, and operational.

The opportunity is not simply sending crypto payouts.

The opportunity is building the layer that turns customer payments into trusted partner balances and controlled payout workflows.

OxaPay provides the useful infrastructure primitives: invoice creation, callback URLs, webhooks, payment history, payout creation, payout information, payout history, payout status tracking, SDKs, and automation modules.

But your product must provide the business logic:

  • split rules
  • ledger entries
  • payout queues
  • approval workflows
  • partner dashboards
  • audit logs
  • security controls
  • reconciliation

That is what merchants pay for.

Start with one niche.

Pick one split rule.

Build the ledger first.

Queue payouts before sending them.

Validate every webhook.

Keep audit logs.

Make partner balances explainable.

Then turn the pattern into a productized service.

That is how a simple payout integration becomes a real developer business.

References

Top comments (0)