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
For example:
Provider payment: paid
Order: pending
Fulfillment: not_started
Finance export: included
The payment exists, but the merchant workflow is incomplete.
Another example:
Provider payment: waiting
Order: paid
Fulfillment: completed
Finance export: included
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:
- Collect records from every relevant system.
- Normalize those records.
- Match related records.
- compare their states.
- Detect violations.
- Create a case when the system cannot safely repair the mismatch.
- 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.
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
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)
);
The important separation is:
merchant_orders
payment_sessions
payment_transactions
payment_events
reconciliation_cases
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:
newwaitingpayingpaidmanual_acceptunderpaidrefundingrefundedexpired
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";
}
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
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
- Existing local relationship by
track_id - Exact provider
order_id - Exact external order ID stored in metadata
- Customer email, amount, currency, and narrow time window
- Customer-specific static address assignment
- Transaction hash supplied by support
- 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",
};
}
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
Here are the most important rules.
Paid payment without an order
Provider: paid
Order: missing
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
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
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
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
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
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
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;
}
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");
}
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,
},
});
}
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
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;
}
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
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
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
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
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_idandorder_idmatching - 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?
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_idvalues - merchant
order_idreferences - 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.
Top comments (0)