Most developers understand card payments through a familiar mental model: a checkout form, a payment processor, an authorization response, and eventually a settlement report.
Crypto payments look similar on the surface.
A customer clicks a button. A payment page opens. The customer sends funds. Your backend receives a callback. The order becomes paid.
But under the hood, the logic is very different.
A crypto payment gateway does not ask a bank to authorize a charge. It watches public blockchain networks, generates payment instructions, matches transactions to invoices, waits for network confirmation, handles asset and network differences, and translates on-chain activity into a payment state your application can understand.
That translation layer is the real product.
A gateway is not just a “crypto checkout page.” It is infrastructure that sits between your application, your customer’s wallet, and one or more blockchain networks.
In this article, we will go deep into how crypto payment gateways work from a developer’s perspective: invoice creation, payment routing, address generation, blockchain monitoring, transaction matching, confirmations, webhook security, reconciliation, settlement, and the state models that keep production systems sane.
To keep the examples concrete, I’ll use OxaPay as one implementation reference. The architecture itself applies to most crypto payment gateways, but using one real API makes the flow easier to understand: invoice generation, hosted payment URLs, track_id references, webhook callbacks, payment status tracking, and payment information lookups.
The Short Version
A crypto payment gateway does three core jobs:
1. It creates a payment request your customer can complete.
2. It watches blockchain networks for matching transactions.
3. It tells your application when the payment state changes.
That sounds simple.
But production payment systems need more than that.
A real gateway also needs to handle:
- multiple coins
- multiple blockchain networks
- dynamic exchange rates
- invoice expiration
- underpayments
- overpayments
- duplicate callbacks
- missed callbacks
- transaction confirmation delays
- payment status transitions
- ledger updates
- settlement behavior
- support and reconciliation workflows
So the better definition is:
A crypto payment gateway converts unpredictable on-chain activity into reliable application-level payment states.
That is the mental model developers should start with.
The Full Payment Flow
Here is the end-to-end flow at a high level:
Customer
|
| clicks "Pay with Crypto"
v
Merchant Frontend
|
| asks backend to create a payment session
v
Merchant Backend
|
| POST /payment/invoice
v
Crypto Payment Gateway
|
| creates invoice, payment URL, tracking ID
v
Merchant Backend
|
| stores local payment record
v
Customer
|
| opens hosted payment page
v
Gateway Checkout
|
| customer selects asset/network and sends funds
v
Blockchain Network
|
| transaction appears in mempool/block
v
Gateway Monitoring Layer
|
| detects, matches, confirms transaction
v
Gateway Status Engine
|
| updates invoice status
v
Merchant Webhook Endpoint
|
| validates callback and updates internal state
v
Merchant Order System
|
| fulfills order only once
A few things are important here.
The merchant backend creates the invoice.
The frontend does not talk to the payment gateway directly with secret credentials.
The blockchain does not know anything about your order.
The gateway matches blockchain activity to the invoice.
Your application still needs its own payment state machine.
If you treat the gateway as a magic black box, the first happy-path transaction may work.
If you treat it as a state translation layer, your integration has a much better chance of surviving production.
The Three Worlds in a Crypto Payment
Every crypto payment integration connects three separate worlds.
1. Merchant world
2. Gateway world
3. Blockchain world
Each world has a different view of the same payment.
1. Merchant World
This is your application.
It understands:
- users
- carts
- orders
- subscriptions
- invoices
- fulfillment
- stock
- access control
- refunds
- support tickets
- accounting records
Your application does not naturally understand block confirmations, transaction hashes, gas fees, or mempool delays.
It needs business-level states like:
waiting_for_payment
payment_detected
paid
underpaid
expired
manual_review
fulfilled
2. Gateway World
The gateway understands payment sessions.
It tracks:
- invoice ID or
track_id - payment amount
- pricing currency
- selected crypto asset
- selected network
- expected amount
- received amount
- transaction hash
- confirmations
- payment status
- callback delivery
- settlement behavior
This is the translation layer between business logic and blockchain activity.
3. Blockchain World
The blockchain understands transactions.
It does not care about your order ID.
It only sees:
- sender address
- receiver address
- amount
- asset or token contract
- network fees
- transaction hash
- block inclusion
- confirmations
- finality properties
A blockchain transaction does not say:
This is payment for Order #12345.
The gateway and your application create that meaning.
Why Gateways Exist
A developer could theoretically integrate directly with blockchain nodes.
You could run your own Bitcoin node, Ethereum node, TRON infrastructure, token indexers, address management system, confirmation engine, and wallet monitoring layer.
But then you would need to solve a large set of problems:
Address generation
Private key security
Network-specific transaction parsing
Token contract monitoring
RPC reliability
Chain reorg handling
Confirmation policies
Exchange-rate locking
Invoice expiration
Underpayment handling
Webhook delivery
Ledger accounting
Settlement operations
Support tooling
That is a lot of infrastructure just to accept payments.
A crypto payment gateway abstracts most of this work behind APIs and callbacks.
For developers, the value is not only “accept BTC or USDT.”
The real value is not having to build and maintain multi-chain payment infrastructure yourself.
Gateway Architecture
A production-grade crypto payment gateway usually has several internal layers.
+--------------------------------------------------+
| Merchant Application |
| Orders, users, subscriptions, fulfillment |
+--------------------------+-----------------------+
|
v
+--------------------------------------------------+
| Gateway API Layer |
| Invoice creation, payment lookup, auth, limits |
+--------------------------+-----------------------+
|
v
+--------------------------------------------------+
| Payment Orchestration Layer |
| Invoice state, amount rules, expiration, routing |
+--------------------------+-----------------------+
|
v
+--------------------------------------------------+
| Address & Routing Layer |
| Dynamic addresses, static addresses, asset/network|
+--------------------------+-----------------------+
|
v
+--------------------------------------------------+
| Blockchain Monitoring Layer |
| Nodes, RPCs, indexers, transaction detection |
+--------------------------+-----------------------+
|
v
+--------------------------------------------------+
| Confirmation & Risk Policy Layer |
| Confirmations, finality rules, reorg awareness |
+--------------------------+-----------------------+
|
v
+--------------------------------------------------+
| Notification Layer |
| Webhooks, retries, callback signing |
+--------------------------+-----------------------+
|
v
+--------------------------------------------------+
| Ledger & Settlement Layer |
| Balances, conversion, withdrawals, reporting |
+--------------------------------------------------+
Not every gateway exposes all of these layers to developers.
But every serious gateway has to solve most of these problems internally.
When you call a simple API endpoint, you are triggering a much larger payment workflow.
Step 1: The Merchant Creates an Invoice
The payment flow usually starts in your backend.
Your customer clicks a “Pay with Crypto” button.
Your backend creates an invoice through the gateway API.
With OxaPay, the invoice endpoint is:
POST https://api.oxapay.com/v1/payment/invoice
A simplified request looks like this:
curl -X POST https://api.oxapay.com/v1/payment/invoice \
-H "merchant_api_key: $OXAPAY_MERCHANT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 100,
"currency": "USD",
"lifetime": 30,
"callback_url": "https://merchant.example.com/webhooks/oxapay",
"return_url": "https://merchant.example.com/orders/ORD-12345",
"order_id": "ORD-12345",
"description": "Order #12345",
"sandbox": true
}'
This is not the payment itself.
It is the creation of a payment request.
A successful response returns a provider reference and a hosted payment URL.
Example:
{
"data": {
"track_id": "184747701",
"payment_url": "https://pay.oxapay.com/12373985/184747701",
"expired_at": 1734546589,
"date": 1734510589
},
"message": "Operation completed successfully!",
"error": {},
"status": 200,
"version": "1.0.0"
}
Two values are especially important:
| Field | Why it matters |
|---|---|
track_id |
The provider-side payment session reference. Store it. |
payment_url |
The hosted checkout page where the customer completes the payment. |
Your backend should immediately store the invoice locally before redirecting the user.
A good local record might include:
CREATE TABLE payments (
id BIGSERIAL PRIMARY KEY,
order_id TEXT NOT NULL,
provider TEXT NOT NULL,
provider_track_id TEXT NOT NULL UNIQUE,
amount NUMERIC(18, 8) NOT NULL,
pricing_currency TEXT NOT NULL,
gateway_status TEXT NULL,
payment_status TEXT NOT NULL,
business_status TEXT NOT NULL,
payment_url TEXT NOT NULL,
expires_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
The provider_track_id is what lets your system connect webhooks, payment lookups, support investigations, and reconciliation jobs back to the original order.
Do not skip this.
Step 2: The Gateway Creates a Payment Session
After the API request, the gateway creates an internal payment session.
That session usually includes:
- merchant account
- order reference
- amount
- pricing currency
- expiration time
- callback URL
- return URL
- supported assets
- supported networks
- invoice status
- payment URL
- risk and settlement settings
At this stage, no blockchain transaction exists.
The gateway has created a structured request for payment.
This is similar to creating a PaymentIntent in card payment systems, but with a major difference:
In crypto, the customer must initiate an on-chain transfer from their wallet or exchange.
The gateway cannot “pull” funds from the customer the way a card processor can request authorization from a card network.
The customer pushes funds to a blockchain address.
That difference changes everything.
Step 3: The Customer Selects Asset and Network
In card payments, the customer usually enters card details and submits.
In crypto payments, the customer chooses how to pay.
That usually means selecting both:
asset + network
For example:
USDT on TRON
USDT on Ethereum
USDT on BNB Smart Chain
USDC on Polygon
BTC on Bitcoin
ETH on Ethereum
This distinction matters.
From a user’s perspective, “I paid with USDT” sounds complete.
From a payment system’s perspective, it is incomplete.
USDT on TRON and USDT on Ethereum are not the same payment route. They have different networks, fees, transaction formats, confirmation behavior, and wallet compatibility.
A gateway must guide the user toward a valid asset/network pair and generate or display payment instructions that match that route.
If the user sends the right asset on the wrong network, the result may not be recoverable automatically.
This is one reason hosted crypto checkout pages are valuable.
They reduce the chance that your application presents incomplete or unsafe payment instructions.
Step 4: Address Generation and Payment Routing
After asset and network selection, the gateway needs a way to identify the incoming transaction.
There are several common models.
Dynamic Invoice Address
A unique address or payment route is generated for a specific invoice.
This is clean for matching because funds sent to that address are associated with that invoice.
Invoice A → Address A
Invoice B → Address B
Invoice C → Address C
This is useful for checkout-style payments.
Static Address
A fixed address is assigned to a user, customer, or account.
Customer 123 → Static Address X
This is useful for deposits, recurring top-ups, exchanges, wallets, gaming balances, and platforms where the same user may pay multiple times.
Static addresses need stronger internal accounting because multiple deposits may arrive over time.
White Label Flow
The merchant controls more of the checkout UX and uses the gateway as infrastructure.
This is useful when the payment experience must stay inside the merchant’s product.
Each model has tradeoffs.
Dynamic invoices are simpler for one-off orders.
Static addresses are better for repeated deposits.
White label flows give more UX control but require stronger integration discipline.
The gateway’s job is to turn the merchant’s payment request into a blockchain-observable route.
Step 5: The Customer Sends the Transaction
Once the customer sees payment instructions, they send funds from a wallet or exchange.
This introduces real-world uncertainty.
The customer might:
- send the exact amount
- send too little
- send too much
- send funds late
- use the wrong network
- close the checkout tab
- pay from an exchange with delayed withdrawals
- pay after the invoice expires
- contact support before confirmation completes
Your code should assume that users will not always follow the perfect path.
That is why the gateway and merchant system need state machines, not just success/failure booleans.
Step 6: The Gateway Monitors Blockchain Networks
The gateway now watches blockchain networks for matching activity.
Depending on the asset and network, this may involve:
- full nodes
- RPC providers
- indexers
- mempool listeners
- block scanners
- token contract event listeners
- deposit address monitors
- internal transaction matching services
For native coins like BTC or ETH, the gateway watches native transfers.
For tokens like USDT or USDC, the gateway may monitor token contract transfer events on the selected network.
The gateway needs to answer:
Did funds arrive?
Which invoice do they belong to?
Which network was used?
Which token contract was involved?
How much was sent?
How much was received?
Has the transaction confirmed?
How many confirmations does it have?
Is it safe to mark the invoice paid?
This is where crypto gateways do a lot of invisible work.
To the merchant, the API may look simple.
Internally, the gateway is continuously reconciling blockchain activity against payment sessions.
Step 7: Transaction Matching
Detecting a transaction is not enough.
The gateway must match it to an invoice.
Matching can use different signals:
- destination address
- expected amount
- selected asset
- selected network
- invoice expiration window
- transaction hash
- memo/tag/payment ID where applicable
- customer or static address reference
A simple invoice match might look like this:
Expected:
Invoice: ORD-12345
Amount: 100 USD
Selected route: USDT on TRON
Payment address: TXabc...
Expires at: 12:30
Observed:
Incoming transfer
Asset: USDT
Network: TRON
Address: TXabc...
Value: approximately 100 USD
Time: 12:21
Result:
Match transaction to invoice ORD-12345
But production is not always simple.
Here are common matching complications:
| Scenario | What can happen |
|---|---|
| Partial payment | Customer sends less than required |
| Overpayment | Customer sends more than required |
| Late payment | Funds arrive after invoice expiration |
| Wrong network | Asset sent on an unsupported or different network |
| Multiple transactions | Customer pays the invoice in more than one transfer |
| Exchange delay | Customer initiates withdrawal, but chain transaction appears later |
| Duplicate-looking payments | Similar amounts arrive near the same time |
| Static address deposits | Same address receives many payments over time |
This is why invoice design matters.
A gateway is not only watching chains. It is applying payment interpretation logic.
Step 8: Confirmation Handling
Once a transaction is detected, the gateway usually does not mark it as final immediately.
It waits for confirmation.
A confirmation means the transaction has been included in a block and has some level of network acceptance.
Different networks have different confirmation behavior.
But developers should avoid hardcoding simplistic rules like:
BTC = 6 confirmations
ETH = 12 confirmations
TRON = 1 confirmation
Those numbers may be useful as rough examples, but production gateways usually manage confirmation policy based on network behavior, risk, asset, amount, and their own infrastructure.
A better mental model is:
transaction_detected
→ confirming
→ confirmed
→ paid
The merchant should not need to monitor block depth directly.
But the merchant should understand the difference between “seen” and “paid.”
A transaction seen in a block explorer does not always mean your business should fulfill the order instantly.
Confirmed Is Not the Same as Business-Final
This is one of the most important payment architecture lessons.
A gateway may report that a transaction is confirmed.
Your business may still decide to hold the order.
For example:
- low-value digital product: fulfill immediately after paid
- high-value physical item: hold for review
- suspicious order: route to manual review
- subscription: activate access but monitor risk
- B2B invoice: mark paid but wait for accounting reconciliation
- large order: require stronger internal approval before shipping
That means you should not have only one status.
You need at least three layers:
gateway_status → what the provider says
payment_status → what your payment system believes
business_status → what your product shows or does
Example:
| Layer | Example Value | Meaning |
|---|---|---|
gateway_status |
paid |
Gateway says invoice is fully paid |
payment_status |
paid_pending_review |
Your payment system accepted it but requires review |
business_status |
on_hold |
Customer access or fulfillment is temporarily held |
This may sound like over-engineering.
It is not.
It prevents confusion between engineering, support, finance, and product teams.
A support agent should not have to wonder:
The gateway says paid. Why is the user still waiting?
The answer should be visible in your internal state model.
Step 9: Gateway Statuses
Gateways usually expose payment statuses to merchants.
OxaPay’s payment status model includes statuses such as:
| Gateway Status | Meaning |
|---|---|
new |
Invoice created; payer has not selected payment currency yet |
waiting |
Payer selected payment currency; awaiting payment |
paying |
Payer is attempting to complete the invoice payment |
paid |
Invoice has been fully paid |
manual_accept |
Invoice was manually accepted |
underpaid |
Payer made a partial payment |
refunding |
Refund request started |
refunded |
Payment was refunded |
expired |
Invoice was not paid within the required time frame |
Your application should map those statuses into its own internal model.
Example:
function normalizeStatus(status) {
return String(status || "").toLowerCase();
}
function mapGatewayStatusToPaymentStatus(gatewayStatus) {
const status = normalizeStatus(gatewayStatus);
const map = {
new: "created",
waiting: "waiting_for_payment",
paying: "payment_detected",
paid: "paid",
manual_accept: "paid_manually_accepted",
underpaid: "underpaid",
refunding: "refunding",
refunded: "refunded",
expired: "expired"
};
return map[status] || "manual_review";
}
Never scatter provider status logic across your codebase.
Put status mapping in one place.
That way, if you change providers or adjust payment policy later, you do not have to rewrite your entire order system.
Step 10: Webhook Notification
When the gateway detects a status change, it sends a webhook to your backend.
In OxaPay, you provide a callback_url when creating the invoice. Payment updates are sent to that URL.
A webhook is not a frontend redirect.
It is a server-to-server notification.
That distinction matters.
return_url = where the customer is redirected
callback_url = where the gateway sends payment updates
Your application should never treat the return URL as proof of payment.
The customer returning to your site only proves that the customer returned.
The webhook tells your backend that the gateway has a payment update.
A good webhook endpoint should:
1. Receive raw body
2. Validate signature
3. Store event
4. Deduplicate event
5. Load payment by track_id/order_id
6. Apply valid state transition
7. Trigger business action only once
8. Return HTTP 200 with "ok"
Step 11: Callback Security
Fake callbacks are a real risk.
If your webhook endpoint accepts any JSON payload and marks orders as paid, anyone could attempt to trigger fulfillment.
That is why callback signature validation is mandatory.
OxaPay sends an HMAC signature in the HMAC header. The signature is generated using HMAC SHA-512 over the raw POST body with the Merchant API Key as the shared secret.
The key phrase is:
raw POST body
Do not parse the JSON and then stringify it again before calculating the HMAC.
Even small formatting changes can produce a different signature.
Here is a Node.js + Express example:
import express from "express";
import crypto from "crypto";
const app = express();
function verifyOxaPayHmac(rawBody, receivedHmac) {
if (!receivedHmac) return false;
const calculatedHmac = crypto
.createHmac("sha512", process.env.OXAPAY_MERCHANT_API_KEY)
.update(rawBody)
.digest("hex");
const receivedBuffer = Buffer.from(receivedHmac, "hex");
const calculatedBuffer = Buffer.from(calculatedHmac, "hex");
if (receivedBuffer.length !== calculatedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(receivedBuffer, calculatedBuffer);
}
app.post(
"/webhooks/oxapay",
express.raw({ type: "application/json" }),
async (req, res) => {
const receivedHmac = req.header("HMAC");
const rawBody = req.body;
if (!verifyOxaPayHmac(rawBody, receivedHmac)) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(rawBody.toString("utf8"));
await handlePaymentWebhook(event, rawBody);
return res.status(200).send("ok");
}
);
The webhook response matters too.
If your endpoint returns an error or does not respond correctly, the gateway may retry the webhook later.
Your webhook handler must therefore be idempotent.
Step 12: Idempotency
Idempotency means the same event can be processed more than once without causing duplicate side effects.
This is critical in payments.
A webhook can be retried.
Your server can timeout after processing but before responding.
A queue worker can crash and restart.
The same payment update can arrive again.
A dangerous implementation looks like this:
if (event.status === "paid") {
await markOrderAsPaid(event.order_id);
await deliverProduct(event.order_id);
}
This may deliver twice if the webhook is received twice.
A safer implementation stores events first.
CREATE TABLE payment_events (
id BIGSERIAL PRIMARY KEY,
provider TEXT NOT NULL,
provider_track_id TEXT NOT NULL,
event_hash TEXT NOT NULL UNIQUE,
gateway_status TEXT NULL,
raw_payload JSONB NOT NULL,
received_at TIMESTAMP NOT NULL DEFAULT NOW(),
processed_at TIMESTAMP NULL
);
Then deduplicate:
function createEventHash(rawBody) {
return crypto
.createHash("sha256")
.update(rawBody)
.digest("hex");
}
async function storePaymentEvent(event, rawBody) {
const eventHash = createEventHash(rawBody);
try {
await db.payment_events.insert({
provider: "oxapay",
provider_track_id: event.track_id,
event_hash: eventHash,
gateway_status: normalizeStatus(event.status),
raw_payload: event
});
return { duplicate: false, eventHash };
} catch (error) {
if (error.code === "23505") {
return { duplicate: true, eventHash };
}
throw error;
}
}
Then process safely:
async function handlePaymentWebhook(event, rawBody) {
const stored = await storePaymentEvent(event, rawBody);
if (stored.duplicate) {
return;
}
const payment = await db.payments.findOne({
provider: "oxapay",
provider_track_id: event.track_id
});
if (!payment) {
await flagForManualReview(event, "payment_not_found");
return;
}
const paymentStatus = mapGatewayStatusToPaymentStatus(event.status);
await applyPaymentTransition(payment, paymentStatus, event);
await db.payment_events.update({
event_hash: stored.eventHash,
processed_at: new Date()
});
}
Business side effects should also be idempotent.
For example, create a unique fulfillment record per order:
CREATE TABLE order_fulfillments (
id BIGSERIAL PRIMARY KEY,
order_id TEXT NOT NULL UNIQUE,
payment_id BIGINT NOT NULL,
fulfilled_at TIMESTAMP NOT NULL DEFAULT NOW()
);
That unique constraint is not just database design.
It is a payment safety mechanism.
Step 13: Applying State Transitions
Payment states should not move randomly.
Define valid transitions.
Example:
created
→ waiting_for_payment
→ payment_detected
→ paid
But production flows also need branches:
created
→ expired
waiting_for_payment
→ expired
→ payment_detected
→ underpaid
payment_detected
→ paid
→ underpaid
→ manual_review
paid
→ refunding
→ refunded
A simple transition guard can prevent bad state changes.
const allowedTransitions = {
created: ["waiting_for_payment", "payment_detected", "paid", "expired", "manual_review"],
waiting_for_payment: ["payment_detected", "paid", "underpaid", "expired", "manual_review"],
payment_detected: ["paid", "underpaid", "manual_review"],
underpaid: ["paid", "manual_review", "expired"],
paid: ["refunding", "refunded"],
refunding: ["refunded"],
refunded: [],
expired: ["paid", "manual_review"],
manual_review: ["paid", "expired", "refunded"]
};
function canTransition(from, to) {
return allowedTransitions[from]?.includes(to) || from === to;
}
Notice that expired → paid may be allowed.
Why?
Because real crypto payments can arrive after the invoice expires.
Your business may decide not to auto-fulfill that order, but the payment event still needs to be recorded and investigated.
A rigid state model that cannot represent late payment creates operational blind spots.
Step 14: Underpayments and Overpayments
Underpayment is common in crypto.
A user may send less than expected because:
- they entered the amount manually
- wallet or exchange fees affected the transfer
- they selected the wrong network
- price conversion changed before they paid
- they split payment incorrectly
Overpayment can happen too.
A gateway needs rules for this.
At the merchant level, you need business policy.
For example:
| Case | Possible Handling |
|---|---|
| Minor underpayment | Accept within configured tolerance |
| Major underpayment | Hold order and ask customer to complete payment |
| Overpayment | Accept payment and record excess |
| Late payment | Manual review or recreate order |
| Wrong network | Support/recovery process if possible |
OxaPay supports parameters such as under_paid_coverage and mixed_payment, which can help define how underpayments or remaining amounts are handled in the invoice request.
But even if the gateway gives you tools, your application still needs a policy.
Do not let support invent policy one ticket at a time.
Step 15: Exchange Rates and Invoice Expiration
Many merchants price products in fiat currency but accept crypto.
That creates a conversion problem.
If an item costs $100, the gateway has to calculate how much BTC, ETH, USDT, or another asset should be paid.
For stablecoins, this may be relatively simple.
For volatile assets, the quote should expire.
That is why crypto invoices usually have a lifetime.
Example:
{
"amount": 100,
"currency": "USD",
"lifetime": 30
}
The lifetime defines how long the payment link remains valid.
A short lifetime reduces price drift.
A longer lifetime improves user convenience.
There is no perfect default for every business.
A digital product checkout may use a shorter window.
A B2B invoice may need a longer payment window.
A deposit flow may use static addresses instead of expiring invoices.
The key is to align expiration policy with the payment use case.
Step 16: Reconciliation
Webhooks are important, but webhook-only systems are fragile.
Your webhook endpoint may be down.
The gateway may retry, but your internal worker may fail after receiving the event.
A database transaction may roll back.
A support ticket may reveal a payment that your system missed.
That is why reconciliation exists.
Reconciliation compares your local view with the provider’s view.
OxaPay provides a Payment Information endpoint:
GET https://api.oxapay.com/v1/payment/{track_id}
Example:
curl -X GET https://api.oxapay.com/v1/payment/184747701 \
-H "merchant_api_key: $OXAPAY_MERCHANT_API_KEY" \
-H "Content-Type: application/json"
A reconciliation job can run periodically:
Find unresolved payments
→ Fetch provider payment by track_id
→ Compare gateway_status with local state
→ Apply safe transition
→ Flag mismatches for manual review
Example:
async function reconcilePayments() {
const payments = await db.payments.findMany({
provider: "oxapay",
payment_status_in: [
"created",
"waiting_for_payment",
"payment_detected",
"underpaid",
"manual_review"
]
});
for (const payment of payments) {
const providerPayment = await fetchOxaPayPayment(payment.provider_track_id);
const gatewayStatus = providerPayment.data.status;
const nextStatus = mapGatewayStatusToPaymentStatus(gatewayStatus);
if (canTransition(payment.payment_status, nextStatus)) {
await applyPaymentTransition(payment, nextStatus, providerPayment.data);
} else {
await flagForManualReview(providerPayment.data, "invalid_transition");
}
}
}
async function fetchOxaPayPayment(trackId) {
const response = await fetch(`https://api.oxapay.com/v1/payment/${trackId}`, {
method: "GET",
headers: {
"merchant_api_key": process.env.OXAPAY_MERCHANT_API_KEY,
"Content-Type": "application/json"
}
});
const result = await response.json();
if (!response.ok || result.status !== 200) {
throw new Error(`Payment lookup failed: ${JSON.stringify(result.error)}`);
}
return result;
}
This is the difference between a demo integration and a production payment system.
A demo waits for the webhook.
A production system can recover if the webhook path fails.
Step 17: Settlement Is a Separate Concern
Payment completion and settlement are not the same thing.
A customer can complete a payment, but your business still has to decide what happens to the received funds.
Possible settlement decisions include:
- keep funds in the received asset
- convert received crypto to a stablecoin
- withdraw funds to an external wallet
- keep funds in the gateway balance
- route funds differently by currency or network
- reconcile balances for accounting
This is where crypto payments become operational.
For example, a merchant might accept several coins at checkout but prefer to hold USDT for accounting simplicity.
Another merchant may want automatic withdrawal to its own treasury wallet.
A platform may need to split internal balances across users or merchants.
Developers often underestimate this part because the first integration focuses on checkout.
But finance and operations care about what happens after the invoice is paid.
Gateway vs Merchant Ledger
A gateway may show that funds were received.
Your application may show that an order was paid.
Your finance system may show that revenue was recognized.
These are related but not identical records.
A mature system separates:
Payment event → what happened externally
Payment state → what your system believes
Business action → what your product did
Ledger entry → what your finance system records
Settlement movement → where funds eventually go
This separation is especially important for:
- marketplaces
- SaaS subscriptions
- gaming platforms
- wallet-like products
- digital goods
- high-volume e-commerce
- platforms with refunds or credits
If your system only has order.paid = true, you will eventually outgrow it.
Crypto Gateway vs Traditional Payment Gateway
The API surface may look familiar, but the underlying payment model is different.
| Area | Traditional Card Gateway | Crypto Payment Gateway |
|---|---|---|
| Payment initiation | Merchant requests authorization | Customer pushes funds |
| Network | Card/bank network | Blockchain network |
| Identity of payment | Authorization/payment intent | Invoice/address/transaction hash |
| Settlement | Processor/acquirer timeline | On-chain + gateway settlement rules |
| Reversibility | Chargebacks possible | On-chain payments are generally irreversible |
| Failure modes | Declines, fraud checks, disputes | Wrong network, underpayment, delayed confirmation |
| State updates | Processor events | Blockchain detection + gateway webhooks |
| Main developer risk | Handling auth/capture/refund correctly | Handling asynchronous state and finality correctly |
The biggest difference is control.
In card payments, the merchant asks the network to authorize money movement.
In crypto payments, the customer initiates a transfer, and the system observes and interprets it.
That is why crypto payment integrations are more event-driven.
Common Developer Mistakes
Mistake 1: Treating Invoice Creation as Payment Success
Invoice creation only means a payment request exists.
No funds have moved yet.
Mistake 2: Trusting the Return URL
A return URL is a browser navigation event.
It is not payment proof.
Mistake 3: Skipping Webhook Signature Validation
Unsigned payment callbacks should never update order state.
Mistake 4: Not Storing Gateway Events
If you do not store raw webhook events, debugging becomes guesswork.
Mistake 5: Ignoring Duplicate Webhooks
Retries are normal.
Duplicate-safe processing is mandatory.
Mistake 6: Using One Status for Everything
paid is not enough.
Separate gateway status, internal payment status, and business status.
Mistake 7: No Reconciliation
If the webhook path fails, your system needs a recovery path.
Mistake 8: No Support View
Support should be able to search by:
order_id
track_id
transaction hash
gateway status
payment status
business status
If support cannot see the payment state, engineering becomes the support dashboard.
Production Checklist
Before going live with a crypto payment gateway, check these items.
API keys are server-side only
Invoice creation happens in backend
track_id is stored locally
payment_url is stored locally
callback_url uses HTTPS
return_url is not treated as payment proof
webhook HMAC is validated using raw body
webhook events are stored before processing
duplicates are ignored safely
gateway statuses are mapped to internal statuses
paid status triggers fulfillment only once
underpaid status has a clear policy
expired invoices can still handle late payments
reconciliation job exists
support can inspect payment state
logs include order_id, track_id, and tx_hash
sandbox flow is tested before production
This checklist is not theoretical.
Most real production issues happen because one of these pieces is missing.
A Minimal Production-Ready Flow
If I were building a simple but reliable crypto payment integration today, I would start with this architecture:
POST /orders/:id/pay/crypto
→ create gateway invoice
→ store local payment row
→ return payment_url
GET /orders/:id/payment-status
→ return internal payment_status + business_status
POST /webhooks/oxapay
→ validate HMAC
→ store raw event
→ deduplicate
→ map gateway_status
→ update payment state
→ fulfill once if allowed
CRON /jobs/reconcile-payments
→ find unresolved payments
→ fetch provider payment by track_id
→ repair mismatches
→ flag risky cases
This is still compact.
But it gives you the core reliability primitives:
state
events
idempotency
security
reconciliation
supportability
That is what a payment system needs.
Example Implementation: Where OxaPay Fits
In this article, OxaPay is used as a practical implementation reference, not as the only way to build this architecture.
The same gateway model applies to most crypto payment providers: your backend creates a payment request, the gateway watches the blockchain, the gateway sends signed payment updates, and your application maps those updates into internal payment and business states.
OxaPay is useful as an example because it exposes the same gateway building blocks discussed above: invoice creation, hosted payment URLs, callback handling, payment status tracking, and payment lookup by track_id.
That means your application can focus on:
order logic
user experience
fulfillment rules
support workflows
business policy
instead of running blockchain monitoring infrastructure yourself.
A good integration still requires careful backend design.
The gateway does not replace your state model, idempotency logic, or business rules. It gives your application a cleaner payment infrastructure layer to connect with.
Final Thoughts
A crypto payment gateway is not magic.
It is a state translation system.
It takes blockchain activity, which is public, asynchronous, network-specific, and irreversible, and turns it into payment states that applications can use.
For developers, the key is to avoid the “single API call” mindset.
Do not think only in terms of:
create invoice → user pays → order paid
Think in terms of:
payment request
→ customer action
→ blockchain transaction
→ gateway detection
→ confirmation policy
→ signed webhook
→ internal state transition
→ business decision
→ reconciliation
→ settlement
That is the real payment flow.
Once you understand that, integrating a crypto payment gateway becomes much less mysterious.
You are not just adding a payment button.
You are connecting your product to a multi-chain payment system.
And if you design the state model correctly from the beginning, your integration will be far easier to debug, support, scale, and trust in production.
Top comments (3)
Really like that you treat the gateway as infrastructure instead of magic. One thing I’d add from experience: you almost always need a clear separation between “gateway view of the world” and “merchant view of the world.”
Example: the gateway might mark a tx as “confirmed” after 1 confirmation, but on the merchant side we still treat high-value orders as “hold for manual review” until risk checks or KYC are done. If you don’t model that distinction, support teams get confused (“the gateway says confirmed, why can’t the user access their purchase?”).
These layers helped a lot for us:
– gateway_status (what the provider reports)
– payment_status (our internal state machine)
– business_status (what the user sees: waiting / active / on-hold / cancelled)
That extra mapping sounds like overkill but it’s saved us a ton of pain any time we change providers or tweak risk rules.
Absolutely spot-on.
This exact confusion ("gateway says confirmed, so why is the order still pending?") has burned so many teams.
Your three-layer model (gateway_status → payment_status → business_status) is pure gold, and honestly one of the most important real-world lessons developers learn the hard way.
I’m definitely adding a section about this in the next update. Thanks for dropping such a battle-tested insight.
Glad we could help!