DEV Community

kevin.s
kevin.s

Posted on • Edited on

Build a Crypto Payment Reconciliation Tool for Merchants

A merchant's payment provider reports a payment as paid.

The ecommerce system still shows the order as pending.

The customer has not received the product. Support is reviewing a screenshot, finance has a transaction in its export, and the developer is searching through webhook logs.

Every system contains part of the truth.

None of them agrees on what happened.

This is a reconciliation problem.

A crypto payment reconciliation tool compares provider-side payments with merchant-side orders, fulfillment records, and financial data. It detects mismatches, turns them into actionable cases, and gives the merchant a controlled way to resolve them.

This article uses OxaPay as the implementation reference, but the architecture is provider-agnostic. The same model applies whenever a business must connect asynchronous payment events to internal business records.

What payment reconciliation actually means

Reconciliation is not the same as displaying payment history.

A payment dashboard answers:

What payments did the provider record?

A reconciliation system answers:

Do the payment provider, order system, fulfillment system, and finance records describe the same business outcome?

For every order, the system should be able to prove:

  • which payment session belongs to it
  • whether the provider confirmed the payment
  • whether the expected amount was received
  • whether fulfillment happened
  • whether the financial record was exported
  • whether any mismatch still requires attention

A useful reconciliation model compares four states:

Provider payment state
Merchant order state
Fulfillment state
Finance state
Enter fullscreen mode Exit fullscreen mode

For example:

Provider payment: paid
Order: pending
Fulfillment: not_started
Finance export: included
Enter fullscreen mode Exit fullscreen mode

The payment exists, but the merchant workflow is incomplete.

Another example:

Provider payment: waiting
Order: paid
Fulfillment: completed
Finance export: included
Enter fullscreen mode Exit fullscreen mode

This is more serious. The merchant may have delivered a product before receiving a confirmed payment.

The reconciliation tool exists to find these disagreements.

Reconciliation is one part of PaymentOps

A broader Crypto PaymentOps service manages the full operational payment lifecycle.

It may include:

  • payment creation
  • webhook ingestion
  • state management
  • fulfillment
  • alerts
  • support visibility
  • reporting
  • reconciliation

The reconciliation tool has a narrower responsibility.

It must:

  1. Collect records from every relevant system.
  2. Normalize those records.
  3. Match related records.
  4. compare their states.
  5. Detect violations.
  6. Create a case when the system cannot safely repair the mismatch.
  7. preserve an audit trail of the resolution.

This distinction matters.

The reconciliation tool should not become another general merchant dashboard. Its core product is the exception queue.

The invariant behind the system

Before writing code, define the business rules that must always remain true.

Useful invariants include:

Every confirmed provider payment must map to a known merchant record.

Every fulfilled order must have an approved payment state.

Every paid order must either be fulfilled or have an open fulfillment case.

Every provider refund must be reflected in the merchant's access or order state.

Every manual resolution must have an audit record.
Enter fullscreen mode Exit fullscreen mode

These invariants are more useful than asking whether a single status field is correct.

The reconciliation engine continuously tests them.

When an invariant fails, the system either repairs the state safely or creates a case for human review.

The three data sources

A production reconciliation tool should not depend on one event stream.

It needs at least three sources.

Merchant records

These include:

  • orders
  • customers
  • expected amounts
  • expected currencies
  • fulfillment status
  • subscription or access status
  • finance export status

Provider records

With OxaPay, relevant provider-side data can come from:

  • invoice creation responses
  • webhook payloads
  • Payment Information
  • Payment History
  • static address payment records

Operational records

These explain what the merchant's own system did:

  • webhook ingestion logs
  • state transitions
  • fulfillment jobs
  • retry attempts
  • manual overrides
  • support notes
  • exports

The reconciliation layer stores normalized references to all three sources.

Recommended architecture

Merchant Store / SaaS / Bot
          |
          | Orders and fulfillment records
          v
+-----------------------------+
| Reconciliation Database     |
+-----------------------------+
          ^
          |
          | Webhook events
          |
OxaPay Webhook Receiver
          ^
          |
          | Payment changes
          |
       OxaPay
          |
          | Payment Information / History
          v
Scheduled Provider Sync
          |
          v
+-----------------------------+
| Matching Engine             |
+-----------------------------+
          |
          v
+-----------------------------+
| Reconciliation Rules        |
+-----------------------------+
          |
          +--------------------+
          |                    |
          v                    v
 Automatic Repair       Exception Queue
                               |
                               v
                      Support / Operations
Enter fullscreen mode Exit fullscreen mode

Webhook events provide the real-time path.

Scheduled API queries provide the recovery path.

Merchant imports provide the business-side truth.

The reconciliation engine compares them instead of assuming that any single source is always complete.

Build a normalized data model

Do not compare raw API responses directly with ecommerce database rows.

Create a normalized model between them.

CREATE TABLE merchant_orders (
  id UUID PRIMARY KEY,
  merchant_id UUID NOT NULL,
  external_order_id TEXT NOT NULL,
  customer_email TEXT,
  expected_amount NUMERIC(18, 8) NOT NULL,
  expected_currency TEXT NOT NULL,
  order_status TEXT NOT NULL,
  fulfillment_status TEXT NOT NULL DEFAULT 'not_started',
  finance_status TEXT NOT NULL DEFAULT 'not_exported',
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
  UNIQUE (merchant_id, external_order_id)
);

CREATE TABLE payment_sessions (
  id UUID PRIMARY KEY,
  merchant_id UUID NOT NULL,
  order_id UUID REFERENCES merchant_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(18, 8),
  requested_currency TEXT,
  paid_amount NUMERIC(18, 8),
  paid_currency TEXT,
  expires_at TIMESTAMP,
  last_verified_at TIMESTAMP,
  raw_provider_record JSONB,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
  UNIQUE (merchant_id, provider, provider_track_id)
);

CREATE TABLE payment_transactions (
  id UUID PRIMARY KEY,
  payment_session_id UUID NOT NULL REFERENCES payment_sessions(id),
  transaction_hash TEXT,
  amount NUMERIC(18, 8),
  currency TEXT,
  network TEXT,
  address TEXT,
  confirmations INTEGER,
  raw_transaction JSONB,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  UNIQUE (payment_session_id, transaction_hash)
);

CREATE TABLE payment_events (
  id UUID PRIMARY KEY,
  merchant_id UUID NOT NULL,
  provider TEXT NOT NULL DEFAULT 'oxapay',
  provider_track_id TEXT,
  payload_hash TEXT NOT NULL,
  event_status TEXT,
  hmac_valid BOOLEAN NOT NULL,
  raw_payload JSONB NOT NULL,
  received_at TIMESTAMP NOT NULL DEFAULT NOW(),
  processed_at TIMESTAMP,
  UNIQUE (merchant_id, payload_hash)
);

CREATE TABLE reconciliation_cases (
  id UUID PRIMARY KEY,
  merchant_id UUID NOT NULL,
  order_id UUID REFERENCES merchant_orders(id),
  payment_session_id UUID REFERENCES payment_sessions(id),
  case_key TEXT NOT NULL,
  case_type TEXT NOT NULL,
  severity TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'open',
  summary TEXT NOT NULL,
  recommended_action TEXT,
  evidence JSONB,
  assigned_to TEXT,
  resolution_type TEXT,
  resolution_note TEXT,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  resolved_at TIMESTAMP,
  UNIQUE (merchant_id, case_key)
);
Enter fullscreen mode Exit fullscreen mode

The important separation is:

merchant_orders
payment_sessions
payment_transactions
payment_events
reconciliation_cases
Enter fullscreen mode Exit fullscreen mode

An order is not a payment.

A payment session is not necessarily a blockchain transaction.

A payment event is not the current state.

A reconciliation case is not the same as an application error.

Each entity describes a different part of the operational history.

Normalize provider statuses

OxaPay documents payment statuses including:

  • new
  • waiting
  • paying
  • paid
  • manual_accept
  • underpaid
  • refunding
  • refunded
  • expired

Your internal model can map them into operational categories.

OxaPay status Internal state Reconciliation meaning
new created Payment session exists
waiting awaiting_payment Waiting for the customer's transfer
paying confirming Payment activity exists, but fulfillment is not yet approved
paid paid_confirmed Payment can satisfy the order
manual_accept manually_accepted Merchant approval requires an audit record
underpaid underpaid_review Amount requires a merchant decision
expired expired Payment session closed without normal completion
refunding refund_in_progress Refund workflow is active
refunded refunded Order, access, and finance state may need reversal

A simple mapper:

const STATUS_MAP = {
  new: "created",
  waiting: "awaiting_payment",
  paying: "confirming",
  paid: "paid_confirmed",
  manual_accept: "manually_accepted",
  underpaid: "underpaid_review",
  expired: "expired",
  refunding: "refund_in_progress",
  refunded: "refunded",
};

function normalizeProviderStatus(status) {
  return String(status ?? "").trim().toLowerCase();
}

function mapProviderStatus(status) {
  const normalized = normalizeProviderStatus(status);
  return STATUS_MAP[normalized] ?? "unknown";
}
Enter fullscreen mode Exit fullscreen mode

Do not trigger fulfillment from paying.

The paid state is the normal state for approved fulfillment. A manually accepted payment should follow a separate merchant policy and preserve who approved it.

Match payments to orders

Matching is the core technical problem.

The strongest design prevents ambiguity before payment creation.

When creating an OxaPay invoice, send the merchant's internal order identifier as order_id and store the returned track_id immediately.

That creates a direct relationship:

Merchant order ID <-> OxaPay track_id
Enter fullscreen mode Exit fullscreen mode

Real systems still need fallback logic because records may be imported, created manually, or received through static addresses.

Use ordered matching rules.

Recommended matching priority

  1. Existing local relationship by track_id
  2. Exact provider order_id
  3. Exact external order ID stored in metadata
  4. Customer email, amount, currency, and narrow time window
  5. Customer-specific static address assignment
  6. Transaction hash supplied by support
  7. Manual review

Do not silently accept a weak match.

Return both the result and its confidence.

async function findOrderMatch({ merchantId, payment }) {
  const linkedSession = await db.paymentSession.findUnique({
    where: {
      merchantId_provider_providerTrackId: {
        merchantId,
        provider: "oxapay",
        providerTrackId: String(payment.track_id),
      },
    },
    include: {
      order: true,
    },
  });

  if (linkedSession?.order) {
    return {
      order: linkedSession.order,
      confidence: "exact",
      reason: "stored_track_id_link",
    };
  }

  if (payment.order_id) {
    const order = await db.merchantOrder.findUnique({
      where: {
        merchantId_externalOrderId: {
          merchantId,
          externalOrderId: String(payment.order_id),
        },
      },
    });

    if (order) {
      return {
        order,
        confidence: "exact",
        reason: "provider_order_id",
      };
    }
  }

  if (payment.email && payment.amount && payment.currency) {
    const createdAt = new Date(payment.created_at ?? Date.now());
    const windowStart = new Date(createdAt.getTime() - 60 * 60 * 1000);
    const windowEnd = new Date(createdAt.getTime() + 60 * 60 * 1000);

    const candidates = await db.merchantOrder.findMany({
      where: {
        merchantId,
        customerEmail: payment.email,
        expectedAmount: payment.amount,
        expectedCurrency: payment.currency,
        createdAt: {
          gte: windowStart,
          lte: windowEnd,
        },
      },
    });

    if (candidates.length === 1) {
      return {
        order: candidates[0],
        confidence: "probable",
        reason: "email_amount_currency_time",
      };
    }

    if (candidates.length > 1) {
      return {
        order: null,
        confidence: "ambiguous",
        reason: "multiple_candidate_orders",
        candidates,
      };
    }
  }

  return {
    order: null,
    confidence: "none",
    reason: "no_safe_match",
  };
}
Enter fullscreen mode Exit fullscreen mode

A probable match may be useful for suggesting an order to the support agent.

It should not automatically trigger fulfillment unless the merchant has explicitly accepted that risk.

Define reconciliation rules

The engine should test explicit rules instead of relying on one large conditional function.

A rule receives normalized records and returns either:

no mismatch
automatic repair
reconciliation case
Enter fullscreen mode Exit fullscreen mode

Here are the most important rules.

Paid payment without an order

Provider: paid
Order: missing
Enter fullscreen mode Exit fullscreen mode

Possible causes:

  • missing order_id
  • failed local write after invoice creation
  • imported payment
  • incorrect merchant tenant
  • static address deposit
  • deleted order

Recommended action:

Search by order ID, email, amount, address, time, and transaction hash. Do not fulfill until a safe match exists.

Paid order not fulfilled

Provider: paid
Order: paid
Fulfillment: failed or not_started
Enter fullscreen mode Exit fullscreen mode

The customer has paid, but the merchant still owes the product or service.

This should normally be a high-severity case.

Fulfilled order without confirmed payment

Provider: new, waiting, paying, expired, or unknown
Fulfillment: completed
Enter fullscreen mode Exit fullscreen mode

This can indicate:

  • fulfillment triggered too early
  • manual override
  • stale local status
  • incorrect order mapping
  • automation bug

This should normally be critical.

Provider paid, local order pending

Provider: paid
Order: pending
Enter fullscreen mode Exit fullscreen mode

This may be safe to repair automatically when:

  • the payment-to-order relationship is exact
  • the amount and currency satisfy the order
  • no refund state exists
  • no conflicting payment session exists

Underpaid payment

Provider: underpaid
Order: pending
Enter fullscreen mode Exit fullscreen mode

The system should create a review case containing:

  • requested amount
  • paid amount
  • difference
  • currency
  • payment age
  • merchant policy

The merchant may request the remaining amount, manually accept the payment, or cancel the order.

Refunded payment with active access

Provider: refunded
Subscription or access: active
Enter fullscreen mode Exit fullscreen mode

The correct action depends on the merchant's refund and access policy.

Do not revoke access silently unless that policy is defined.

Finance mismatch

Provider payment: paid
Order: paid
Finance export: missing
Enter fullscreen mode Exit fullscreen mode

The payment operation succeeded, but the reporting process is incomplete.

This usually has lower immediate severity than failed fulfillment, but it matters for daily and monthly closing.

Implement the reconciliation evaluator

A focused evaluator can produce cases from one normalized record set.

function evaluateReconciliation({
  payment,
  order,
  fulfillment,
  finance,
}) {
  const findings = [];

  if (payment.internalStatus === "paid_confirmed" && !order) {
    findings.push({
      type: "paid_payment_without_order",
      severity: "high",
      summary: "A confirmed payment has no matching merchant order.",
      recommendedAction:
        "Review order_id, customer email, amount, time, address, and transaction hash.",
    });

    return findings;
  }

  if (!order) {
    return findings;
  }

  if (
    payment.internalStatus === "paid_confirmed" &&
    order.paymentStatus !== "paid"
  ) {
    findings.push({
      type: "provider_paid_local_pending",
      severity: "high",
      summary: `Provider reports payment for order ${order.externalOrderId} as paid, but the local order remains pending.`,
      recommendedAction:
        "Verify the exact order match and update local state if no conflicting payment exists.",
    });
  }

  if (
    payment.internalStatus === "paid_confirmed" &&
    fulfillment.status !== "completed"
  ) {
    findings.push({
      type: "paid_not_fulfilled",
      severity: "high",
      summary: `Order ${order.externalOrderId} is paid but not fulfilled.`,
      recommendedAction:
        "Inspect the fulfillment job and retry it idempotently.",
    });
  }

  if (
    fulfillment.status === "completed" &&
    !["paid_confirmed", "manually_accepted"].includes(
      payment.internalStatus,
    )
  ) {
    findings.push({
      type: "fulfilled_without_approved_payment",
      severity: "critical",
      summary: `Order ${order.externalOrderId} was fulfilled without an approved payment state.`,
      recommendedAction:
        "Review the fulfillment trigger, payment mapping, and manual overrides.",
    });
  }

  if (payment.internalStatus === "underpaid_review") {
    findings.push({
      type: "underpaid_payment",
      severity: "medium",
      summary: `Payment for order ${order.externalOrderId} is underpaid.`,
      recommendedAction:
        "Apply the merchant policy for remaining payment, manual acceptance, or cancellation.",
    });
  }

  if (
    payment.internalStatus === "refunded" &&
    fulfillment.accessStatus === "active"
  ) {
    findings.push({
      type: "refunded_but_access_active",
      severity: "high",
      summary: `Order ${order.externalOrderId} was refunded, but customer access remains active.`,
      recommendedAction:
        "Apply the merchant's refund and access revocation policy.",
    });
  }

  if (
    payment.internalStatus === "paid_confirmed" &&
    finance.status !== "exported"
  ) {
    findings.push({
      type: "paid_not_exported",
      severity: "low",
      summary: `Paid order ${order.externalOrderId} is missing from the finance export.`,
      recommendedAction:
        "Include the record in the next export or inspect the export job.",
    });
  }

  return findings;
}
Enter fullscreen mode Exit fullscreen mode

Each finding should become a deduplicated case.

Prevent duplicate cases

A reconciliation job may run every few minutes.

Without case deduplication, the dashboard could create hundreds of identical open cases.

Generate a stable case key.

import crypto from "node:crypto";

function createCaseKey({
  merchantId,
  type,
  orderId,
  paymentSessionId,
}) {
  return crypto
    .createHash("sha256")
    .update(
      [
        merchantId,
        type,
        orderId ?? "no-order",
        paymentSessionId ?? "no-payment",
      ].join(":"),
    )
    .digest("hex");
}
Enter fullscreen mode Exit fullscreen mode

Then upsert the case:

async function upsertReconciliationCase({
  merchantId,
  orderId,
  paymentSessionId,
  finding,
  evidence,
}) {
  const caseKey = createCaseKey({
    merchantId,
    type: finding.type,
    orderId,
    paymentSessionId,
  });

  return db.reconciliationCase.upsert({
    where: {
      merchantId_caseKey: {
        merchantId,
        caseKey,
      },
    },
    create: {
      merchantId,
      orderId,
      paymentSessionId,
      caseKey,
      caseType: finding.type,
      severity: finding.severity,
      status: "open",
      summary: finding.summary,
      recommendedAction: finding.recommendedAction,
      evidence,
    },
    update: {
      severity: finding.severity,
      summary: finding.summary,
      recommendedAction: finding.recommendedAction,
      evidence,
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

When the mismatch disappears, do not delete the case.

Mark it resolved and record whether the resolution was:

  • automatic
  • manual
  • caused by a corrected provider record
  • caused by a corrected merchant record
  • ignored under merchant policy

That audit history is part of the product.

Use webhooks for speed and API queries for recovery

Webhooks provide fast updates, but they should not be the only reconciliation source.

A callback may be missed because of:

  • server downtime
  • deployment failure
  • firewall configuration
  • invalid endpoint behavior
  • temporary database errors
  • application bugs

With OxaPay, the reconciliation worker can use:

  • Payment Information for a specific track_id
  • Payment History for periodic account-level backfill

A practical schedule might be:

Every 10 minutes:
- query recent provider payments
- upsert normalized payment records
- compare them with local orders
- create or resolve cases

Every night:
- reconcile the previous day
- verify paid totals
- find records missing from either side
- generate an operations report

At month-end:
- run a larger comparison window
- freeze the report inputs
- export unresolved exceptions separately
Enter fullscreen mode Exit fullscreen mode

Use overlapping time windows.

For example, a job that runs at 12:00 should not query only records created after 11:50. Query a wider period and rely on idempotent upserts.

This protects the system from delayed records and temporary job failures.

Payment Information lookup

When the tool knows the track_id, it can retrieve the latest provider record directly.

async function fetchOxaPayPayment({
  merchantApiKey,
  trackId,
}) {
  const response = await fetch(
    `https://api.oxapay.com/v1/payment/${encodeURIComponent(trackId)}`,
    {
      method: "GET",
      headers: {
        merchant_api_key: merchantApiKey,
        "Content-Type": "application/json",
      },
    },
  );

  const payload = await response.json();

  if (!response.ok) {
    throw new Error(
      payload?.error?.message ??
        `Payment lookup failed with ${response.status}`,
    );
  }

  return payload.data;
}
Enter fullscreen mode Exit fullscreen mode

Use this lookup when:

  • support investigates one payment
  • provider and local states disagree
  • a webhook references an unknown local record
  • an expired invoice has reported transaction activity
  • a case requires current evidence before manual resolution

Store the response and the verification time.

Do not overwrite earlier raw evidence. Reconciliation depends on understanding how the state changed.

Decide what can be repaired automatically

Not every mismatch requires a person.

Automatic repair is appropriate only when the evidence is strong and the action is reversible or already authorized.

A provider-paid, local-pending order may be repaired automatically when:

track_id relationship is exact
order_id matches
amount satisfies the order
currency matches policy
payment is not refunded
order is not already linked to another confirmed payment
Enter fullscreen mode Exit fullscreen mode

A payment should normally require manual review when:

no exact order match exists
multiple orders are possible matches
the payment is underpaid
a manual acceptance is required
a refund conflicts with access state
the expected and paid currencies violate merchant policy
the same transaction appears against multiple records
Enter fullscreen mode Exit fullscreen mode

The tool should explain why it selected automatic repair or manual review.

A silent state change is difficult to trust.

An auditable decision is a product feature.

Design the exception queue

The merchant does not need another page full of raw transactions.

The main screen should answer:

What requires attention now?

Useful columns include:

Field Purpose
Severity Prioritize operational risk
Case type Explain the mismatch
Order ID Connect to merchant workflow
Track ID Connect to provider record
Customer Help support investigate
Expected amount Show the order requirement
Paid amount Show provider evidence
Payment status Show normalized provider state
Fulfillment status Show delivery outcome
Case age Surface unresolved problems
Recommended action Tell the operator what to do
Assignee Establish ownership

Useful filters include:

  • critical cases
  • paid but not fulfilled
  • fulfilled without payment
  • unmatched payments
  • underpaid payments
  • refund conflicts
  • finance export failures
  • cases older than one hour
  • manually resolved cases

Show an evidence timeline

A support agent should not reconstruct the incident from five systems.

Show one timeline.

10:03:12  Merchant order created
10:03:13  OxaPay invoice created
10:03:13  track_id linked to order
10:04:42  Webhook received: paying
10:05:11  Webhook received: paid
10:05:12  Payment session updated
10:05:12  Fulfillment job queued
10:05:18  Fulfillment failed
10:10:00  Reconciliation case created
10:12:41  Fulfillment retried
10:12:45  Product delivered
10:20:00  Reconciliation case resolved automatically
Enter fullscreen mode Exit fullscreen mode

The timeline should distinguish:

  • provider evidence
  • merchant state changes
  • automated actions
  • manual actions
  • reconciliation decisions

This gives support a defensible answer when a customer asks what happened.

Static address reconciliation

Static address payments require different matching rules.

An invoice normally represents one expected payment for one order.

A static address may receive multiple payments over time and may represent:

  • a customer account
  • an internal balance
  • a deposit flow
  • a recurring B2B relationship

For static addresses, store:

address
network
currency
track_id
assigned customer or account
assignment date
active state
transaction hashes
credited balance records
Enter fullscreen mode Exit fullscreen mode

Useful reconciliation checks include:

  • transaction received but account not credited
  • duplicate transaction hash
  • payment to an unassigned address
  • payment to an inactive address
  • credited amount different from received amount
  • payment assigned to the wrong customer
  • transaction stored without a finance record

Do not use static addresses as a shortcut when the merchant needs strict order-level matching.

Invoices are usually easier to reconcile for individual purchases.

What the MVP should include

A useful first version does not need advanced analytics or machine learning.

Build:

  • OxaPay payment session storage
  • webhook event ingestion
  • Payment Information lookup
  • scheduled Payment History sync
  • exact track_id and order_id matching
  • normalized payment states
  • five to eight reconciliation rules
  • deduplicated exception cases
  • evidence timeline
  • manual resolution notes
  • CSV export
  • daily unresolved-case summary

The MVP should answer:

Which confirmed payments have no order?

Which paid orders were not fulfilled?

Which fulfilled orders lack an approved payment?

Which payments are underpaid or ambiguous?

Which provider and local records disagree?

What action should the merchant take?
Enter fullscreen mode Exit fullscreen mode

That is enough to solve a real operational problem.

Productize by merchant workflow

A generic reconciliation dashboard is difficult to explain.

A niche-specific result is easier to sell.

Digital product stores

Important cases:

  • paid but download not delivered
  • license key generation failed
  • duplicate license delivery
  • refund completed but license still active

Hosting providers

Important cases:

  • paid but service not provisioned
  • renewal paid but expiry date not extended
  • service activated before confirmed payment
  • refund completed but service still active

SaaS businesses

Important cases:

  • paid but subscription inactive
  • payment attached to the wrong account
  • duplicate subscription extension
  • refunded payment with active premium access

Paid communities

Important cases:

  • payment confirmed but role not granted
  • access granted without confirmed payment
  • renewal payment not applied
  • refunded member still has access

The reconciliation core remains similar.

The value comes from merchant-specific rules and recommended actions.

Production safeguards

A reconciliation product works with financial and operational evidence.

At minimum:

  • validate webhook HMAC signatures
  • preserve raw webhook bodies
  • process events idempotently
  • encrypt API keys
  • keep secrets out of logs
  • use unique transaction hash constraints
  • separate provider state from merchant state
  • record every manual resolution
  • restrict who can accept or override payments
  • run scheduled provider backfills
  • keep failed jobs in a dead-letter queue
  • log exports and administrative actions
  • define retention rules for raw payloads
  • test recovery after temporary outages

The tool should not silently hide uncertainty.

When evidence is incomplete, create a case.

What makes this a real product

A weak product says:

Here are your crypto payments.

A strong reconciliation product says:

Three provider payments have no matching orders.

Two paid orders were not fulfilled.

One order was fulfilled before payment reached an approved state.

Four payments are missing from today's finance export.

Here is the evidence and recommended action for each issue.

The value is not the number of API endpoints connected.

The value is the number of operational gaps detected and resolved before they become customer complaints or financial confusion.

Final takeaway

Crypto payment reconciliation is not a checkout feature.

It is a control system.

It compares provider payments, merchant orders, fulfillment activity, and finance records. It detects when those systems disagree and turns ambiguity into a visible, actionable case.

OxaPay provides the necessary payment primitives:

  • unique payment track_id values
  • merchant order_id references
  • webhook events
  • Payment Information
  • Payment History
  • documented payment statuses
  • static address records

The developer's job is to build the matching rules, reconciliation engine, exception queue, evidence timeline, and resolution workflow around those primitives.

Merchants do not need another transaction table.

They need to know:

  • which records agree
  • which records conflict
  • what created the mismatch
  • what can be repaired automatically
  • what requires a human decision
  • whether the customer received what they paid for

That is the difference between showing payment data and operating payments reliably.

References

Top comments (0)