DEV Community

Cover image for How Crypto Payment Gateways Work: A Developer’s Deep Dive
kevin.s
kevin.s

Posted on • Edited on

How Crypto Payment Gateways Work: A Developer’s Deep Dive

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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       |
+--------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  }'
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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()
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For example:

USDT on TRON
USDT on Ethereum
USDT on BNB Smart Chain
USDC on Polygon
BTC on Bitcoin
ETH on Ethereum
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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";
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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");
  }
);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
  });
}
Enter fullscreen mode Exit fullscreen mode

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()
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

But production flows also need branches:

created
  → expired

waiting_for_payment
  → expired
  → payment_detected
  → underpaid

payment_detected
  → paid
  → underpaid
  → manual_review

paid
  → refunding
  → refunded
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

Example:

curl -X GET https://api.oxapay.com/v1/payment/184747701 \
  -H "merchant_api_key: $OXAPAY_MERCHANT_API_KEY" \
  -H "Content-Type: application/json"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This is still compact.

But it gives you the core reliability primitives:

state
events
idempotency
security
reconciliation
supportability
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Think in terms of:

payment request
→ customer action
→ blockchain transaction
→ gateway detection
→ confirmation policy
→ signed webhook
→ internal state transition
→ business decision
→ reconciliation
→ settlement
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
kajol_shah profile image
Kajol Shah

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.

Collapse
 
kevins1988 profile image
kevin.s

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.

Collapse
 
kajol_shah profile image
Kajol Shah

Glad we could help!