Most developers think about crypto payments as a checkout problem. Create an invoice. Show a payment link. Wait for a callback. Mark the order as paid. That is enough for a demo. It is not enough for a real merchant. Once a business starts receiving meaningful crypto payment volume, the hard question is no longer only: Did the customer pay? The harder questions are: Which order does this payment belong to? Was the amount exact, underpaid, late, duplicated, manually accepted, refunded, or still waiting? Did the order system, support team, finance team, and payment provider all agree on the same state? Can the merchant prove what happened when a customer opens a support ticket? That is the opportunity. A developer can build a crypto payment reconciliation tool for merchants. Not another crypto checkout. Not another wallet dashboard. A tool that connects payment events, invoices, orders, customers, transactions, support records, reports, and exceptions into one operational system. In this article, I will use OxaPay as the example payment infrastructure because its documentation exposes the primitives a developer needs for this kind of product: invoice generation, payment webhooks, payment information lookup, payment history search, static addresses, SDKs, and automation integrations. The goal is not to build a toy script. The goal is to understand how a developer can turn payment reconciliation into a real merchant-facing product or service. The business opportunity Merchants do not usually wake up thinking they need a reconciliation tool. They discover the need when payment volume creates operational friction. At low volume, a merchant can manually check payments. At higher volume, manual checking starts to break down. A customer says they paid, but the order is still pending. An invoice expires, but the blockchain transaction arrives late. A customer pays less than expected. A support agent sees an order ID, but the payment provider stores a track ID. The finance team exports payment history, but the ecommerce platform has a different number of paid orders. The developer who solves this problem is not selling “API integration.” They are selling operational confidence. A reconciliation product helps merchants answer: Which orders are fully paid? Which paid orders were not fulfilled? Which payments are unresolved? Which invoices are expired but have transaction activity? Which payments are underpaid or manually accepted? Which customers need support follow-up? Which payment records should be exported for finance? Which webhook events were received, ignored, duplicated, or failed? That is much more valuable than simply adding a payment button. Who would pay for this? A crypto payment reconciliation tool is not for every merchant. A small seller with five payments per month probably does not need it. The best customers are merchants where payment confusion has a real cost. Good targets include: Digital product stores SaaS platforms Hosting companies VPN and reseller panels Online course platforms Telegram or Discord paid communities Software license sellers Cross-border service businesses Agencies that manage multiple merchant clients Marketplaces or creator platforms that receive many payments The common pattern is simple: they receive payments, deliver something after payment, and need proof that payment state and business state match. This product becomes more valuable when the merchant has: many orders multiple payment methods manual support tickets paid but undelivered orders expired invoice disputes multiple currencies or networks finance reporting needs operational staff who are not developers The first version does not need to be a huge SaaS platform. It can start as a private dashboard, a managed reconciliation service, or a plugin-like internal tool for one niche. What you are building A crypto payment reconciliation tool sits between the merchant’s business system and the payment provider. It receives payment events, stores normalized payment records, compares those records with merchant orders, detects mismatches, and gives humans a clear queue of exceptions. The architecture looks like this: Merchant store / SaaS / bot / CRM ↓ Order created ↓ OxaPay invoice / white-label / static address ↓ Customer payment ↓ Webhook callback ↓ Payment event store ↓ Reconciliation engine ↓ Matched / unresolved / risky / needs review ↓ Dashboard + alerts + finance export + support timeline The product has four main responsibilities: Ingest payment events from webhooks. Backfill and verify payment records using payment information and payment history APIs. Match payments to merchant orders using identifiers such as order_id , track_id , amount, currency, email, address, and time window. Surface exceptions that require action. The most important idea is this: Reconciliation is not just storing payment status. Reconciliation is proving that payment state, order state, fulfillment state, and finance state agree. Why OxaPay has the right primitives for this A reconciliation tool needs more than a checkout URL. It needs persistent identifiers, payment status, callbacks, transaction metadata, history endpoints, and the ability to query records again when the webhook stream is incomplete. OxaPay provides several useful primitives for this. Generate Invoice The Generate Invoice endpoint creates a payment invoice and returns a payment URL. The request can include fields such as amount, currency, lifetime, callback URL, return URL, email, and order_id . That order_id is important. It lets the developer connect the payment session to the merchant’s internal order record from the beginning. Reference: OxaPay Generate Invoice Webhook OxaPay webhooks send JSON notifications to a merchant-defined callback_url when payment status changes. The docs explain that the merchant should wait for the final paid state rather than treating the earlier paying status as final. The webhook guide also documents HMAC SHA-512 callback validation using the merchant API key as the shared secret. It also explains the retry behavior: OxaPay expects an HTTP 200 response with ok , and retries webhook delivery up to five times with increasing delays. Reference: OxaPay Webhook Payment Information The Payment Information endpoint lets a developer retrieve details for a specific payment using its track_id . This is useful for support investigation, webhook recovery, and periodic verification. Reference: OxaPay Payment Information Payment History The Payment History endpoint returns account payment records and supports filtering by criteria such as track_id , type, status, currency, network, address, date window, amount range, sorting, page, and size. This is essential for reconciliation because a robust tool should not depend only on webhooks. It should also run scheduled jobs that compare the merchant’s internal records with provider-side history. Reference: OxaPay Payment History Payment Status Table OxaPay documents payment statuses such as new , waiting , paying , paid , manual_accept , underpaid , refunding , refunded , and expired . A reconciliation tool should map these provider statuses to internal operational states. Reference: OxaPay Payment Status Table Static Address For some merchants, invoice-based payments are not the only model. OxaPay also supports static addresses linked to a unique track_id , with callback support for payments made to that address. This can matter for recurring deposits, account top-ups, wallets, or customer-specific payment addresses. Reference: OxaPay Generate Static Address The core product: from payment records to reconciliation cases The tool should not show merchants a raw table of transactions and call it a day. The product should turn payment data into cases . A case is a business question that needs resolution. Examples: Order paid but not fulfilled Invoice expired but payment activity exists Payment underpaid Payment confirmed but order missing Webhook received but signature invalid Duplicate callback received Provider status differs from local status Payment refunded but order still active Static address payment received without a matching customer action This is where the product becomes valuable. A raw payment dashboard is easy to ignore. A prioritized exception queue is useful. MVP scope Do not start by building a full finance platform. Start with a focused MVP. A strong first version could include: Merchant account connection Invoice creation wrapper Webhook receiver HMAC validation Payment event log Order-to-payment matching Reconciliation status engine Exception queue Manual resolution notes CSV export Daily summary email or Telegram alert The MVP should answer five questions well: Which orders are paid? Which paid orders are not fulfilled? Which payments need review? Which webhook events were missed or duplicated? What should support or finance do next? That is enough to sell to an early merchant. Data model A reconciliation tool needs its own normalized database. Do not rely only on the ecommerce platform database. Do not rely only on the payment provider dashboard. You need a middle layer that can compare both sides. Here is a practical schema. CREATE TABLE merchants ( id UUID PRIMARY KEY , name TEXT NOT NULL , oxapay_merchant_api_key_encrypted TEXT NOT NULL , webhook_secret_hint TEXT , created_at TIMESTAMP NOT NULL DEFAULT NOW () ); CREATE TABLE merchant_orders ( id UUID PRIMARY KEY , merchant_id UUID NOT NULL REFERENCES merchants ( id ), 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_fulfilled' , 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 REFERENCES merchants ( id ), order_id UUID REFERENCES merchant_orders ( id ), provider TEXT NOT NULL DEFAULT 'oxapay' , provider_track_id TEXT NOT NULL , provider_type TEXT NOT NULL , requested_amount NUMERIC ( 18 , 8 ), requested_currency TEXT , provider_status TEXT NOT NULL , internal_status TEXT NOT NULL , invoice_url TEXT , expires_at TIMESTAMP , raw_provider_payload 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 ), tx_hash TEXT , amount NUMERIC ( 18 , 8 ), currency TEXT , network TEXT , address TEXT , tx_status TEXT , confirmations INTEGER , raw_tx_payload JSONB , created_at TIMESTAMP NOT NULL DEFAULT NOW (), UNIQUE ( payment_session_id , tx_hash ) ); CREATE TABLE webhook_events ( id UUID PRIMARY KEY , merchant_id UUID NOT NULL REFERENCES merchants ( id ), provider TEXT NOT NULL DEFAULT 'oxapay' , provider_track_id TEXT , event_status TEXT , hmac_valid BOOLEAN NOT NULL , payload_hash TEXT NOT NULL , raw_payload JSONB NOT NULL , received_at TIMESTAMP NOT NULL DEFAULT NOW (), processed_at TIMESTAMP , processing_status TEXT NOT NULL DEFAULT 'pending' , UNIQUE ( merchant_id , payload_hash ) ); CREATE TABLE reconciliation_cases ( id UUID PRIMARY KEY , merchant_id UUID NOT NULL REFERENCES merchants ( id ), order_id UUID REFERENCES merchant_orders ( id ), payment_session_id UUID REFERENCES payment_sessions ( id ), case_type TEXT NOT NULL , severity TEXT NOT NULL , status TEXT NOT NULL DEFAULT 'open' , summary TEXT NOT NULL , recommended_action TEXT , assigned_to TEXT , resolved_by TEXT , resolution_note TEXT , created_at TIMESTAMP NOT NULL DEFAULT NOW (), resolved_at TIMESTAMP ); This schema is intentionally operational. It is not only for storing payments. It is for investigating mismatches. Internal payment state machine Provider statuses are not always the same as business statuses. A merchant does not only care whether a payment is paid . They care whether the order is safe to deliver, whether support should intervene, and whether finance should include it in reporting. You can map OxaPay statuses into internal states. OxaPay status Internal state Business meaning

new created Invoice exists, no payer action yet waiting awaiting_payment Payer selected currency, waiting for transfer paying confirming Payment attempt seen, not final yet paid paid_confirmed Payment can trigger fulfillment manual_accept manually_accepted Merchant accepted manually; needs audit note underpaid needs_review_underpaid Partial payment; support/finance review expired expired_no_payment_or_late Invoice expired; check for late activity refunding refund_in_progress Refund started refunded refunded Refund completed Do not trigger fulfillment on paying . In OxaPay’s webhook documentation, paying is an earlier status and paid is the status to wait for before treating the payment as confirmed. The reconciliation tool should make that distinction very visible. Webhook receiver with HMAC validation Webhook ingestion is the first technical building block. This example uses Node.js and Express. The important part is that HMAC validation must use the raw request body , not a parsed and re-serialized JSON object. import express from " express " ; import crypto from " crypto " ; const app = express (); // Keep raw body for HMAC verification. app . use ( " /webhooks/oxapay " , express . raw ({ type : " application/json " })); function verifyOxaPayHmac ( rawBody , receivedHmac , merchantApiKey ) { const calculated = crypto . createHmac ( " sha512 " , merchantApiKey ) . update ( rawBody ) . digest ( " hex " ); const a = Buffer . from ( calculated , " hex " ); const b = Buffer . from ( receivedHmac || "" , " hex " ); if ( a . length !== b . length ) return false ; return crypto . timingSafeEqual ( a , b ); } app . post ( " /webhooks/oxapay " , async ( req , res ) => { const rawBody = req . body ; const receivedHmac = req . header ( " HMAC " ); // In a multi-merchant product, resolve the merchant carefully. // You might map by callback URL token, track_id lookup, or tenant route. const merchant = await findMerchantFromWebhook ( rawBody ); if ( ! merchant ) { return res . status ( 400 ). send ( " unknown merchant " ); } const hmacValid = verifyOxaPayHmac ( rawBody , receivedHmac , merchant . oxapayMerchantApiKey ); const payloadText = rawBody . toString ( " utf8 " ); const payloadHash = crypto . createHash ( " sha256 " ) . update ( payloadText ) . digest ( " hex " ); const payload = JSON . parse ( payloadText ); await saveWebhookEvent ({ merchantId : merchant . id , provider : " oxapay " , providerTrackId : payload . track_id , eventStatus : payload . status , hmacValid , payloadHash , rawPayload : payload }); if ( ! hmacValid ) { // Store the event for audit, but do not process it. return res . status ( 401 ). send ( " invalid signature " ); } await enqueuePaymentEventProcessing ({ merchantId : merchant . id , providerTrackId : payload . track_id , payloadHash }); // OxaPay expects HTTP 200 with body "ok" for successful webhook delivery. return res . status ( 200 ). send ( " ok " ); }); A production implementation should process the webhook asynchronously. The endpoint should validate, store, enqueue, and return quickly. Do not run long reconciliation jobs inside the webhook request. Idempotency and duplicate callbacks Webhook retries are normal. Network failures happen. Your endpoint may receive the same event more than once. Your reconciliation tool must be idempotent. Use a unique constraint on a stable event hash: async function saveWebhookEvent ( event ) { try { return await db . webhookEvents . create ({ data : event }); } catch ( err ) { if ( isUniqueViolation ( err )) { return await db . webhookEvents . findUnique ({ where : { merchantId_payloadHash : { merchantId : event . merchantId , payloadHash : event . payloadHash } } }); } throw err ; } } Also make payment state transitions idempotent. If the local session is already paid_confirmed , another paid webhook should not trigger fulfillment twice. async function applyPaymentStatus ( session , providerStatus ) { const nextInternalStatus = mapProviderStatus ( providerStatus ); if ( session . internalStatus === nextInternalStatus ) { return { changed : false , status : session . internalStatus }; } if ( session . internalStatus === " paid_confirmed " ) { return { changed : false , status : session . internalStatus }; } await db . paymentSessions . update ({ where : { id : session . id }, data : { providerStatus , internalStatus : nextInternalStatus , updatedAt : new Date () } }); return { changed : true , status : nextInternalStatus }; } This is not just a code quality issue. It protects the merchant from duplicate fulfillment. Backfill with Payment History A serious reconciliation tool should not trust webhooks as the only source of truth. Webhooks can fail. Servers can be down. Firewalls can block callbacks. A bug can process an event incorrectly. That is why you also need periodic provider-side backfill. OxaPay’s Payment History endpoint supports date filtering, status filtering, payment type filtering, pagination, sorting, and amount filtering. A scheduled job can pull recent records and compare them with local state. async function fetchOxaPayPaymentHistory ({ merchantApiKey , fromDate , toDate , page = 1 }) { const params = new URLSearchParams ({ from_date : String ( fromDate ), to_date : String ( toDate ), size : " 200 " , page : String ( page ), sort_by : " pay_date " , sort_type : " desc " }); const response = await fetch ( https://api.oxapay.com/v1/payment? ${ params } , { method : " GET " , headers : { " merchant_api_key " : merchantApiKey , " Content-Type " : " application/json " } }); if ( ! response . ok ) { throw new Error ( OxaPay history request failed: ${ response . status } ); } return response . json (); } A simple backfill strategy: Every 15 minutes: Pull payments from the last 2 hours. Upsert payment sessions by track_id. Compare provider status with local status. Create case if provider is paid but local order is unpaid.

Every 24 hours: Pull payments from the previous day. Compare total paid amount with merchant order totals. Generate daily reconciliation report. This is where your tool becomes more robust than a basic webhook integration. Payment Information lookup for support The Payment Information endpoint is useful when support needs to investigate one specific payment. For example, a customer writes: I paid, but my order is still pending. The support agent can search by order ID, customer email, track ID, or transaction hash. If the tool has the OxaPay track_id , it can fetch the latest payment details. async function fetchPaymentInformation ({ merchantApiKey , trackId }) { const response = await fetch ( https://api.oxapay.com/v1/payment/ ${ trackId } , { method : " GET " , headers : { " merchant_api_key " : merchantApiKey , " Content-Type " : " application/json " } }); if ( ! response . ok ) { throw new Error ( Payment lookup failed: ${ response . status } ); } return response . json (); } The result can update the support timeline: Order created Invoice generated Customer selected currency Payment status changed to paying Transaction confirmation detected Payment status changed to paid Fulfillment job failed Support case opened This is the kind of view support teams actually need. Matching logic Reconciliation is mostly matching. You are matching provider-side records t