A customer in another country buys a software license.
The checkout displays a price in USD. The customer pays with crypto on a supported network. The payment reaches paid, but the license is never delivered.
Another customer sends funds after a white-label payment address expires.
A third customer pays twice because the first transaction remains in the paying state longer than expected.
The seller accepts international payments.
The international selling workflow does not work reliably.
This is the difference between adding a crypto payment button and building a cross-border payment stack.
A Cross-Border Crypto Payment Stack connects:
Product pricing
-> International checkout
-> Crypto payment
-> Payment verification
-> Digital fulfillment
-> Customer visibility
-> Support
-> Reporting
-> Recovery
Enter fullscreen mode Exit fullscreen mode
This article uses OxaPay as the payment infrastructure reference and a software license seller as the main implementation example.
The architecture is provider-agnostic and can also support digital downloads, courses, SaaS plans, memberships, research products, and remote service packages.
This article is part of 10 Crypto Payment Products Developers Can Build for Merchants.
Cross-border is more than buyer location
A payment does not become cross-border merely because the buyer and seller are in different countries.
A cross-border system must handle differences in:
- language
- pricing currency
- payment asset
- blockchain network
- time zone
- payment instructions
- regional availability
- refund and delivery policies
- support expectations
- financial reporting
Consider this order:
Product price: 49 USD
Buyer locale: de-DE
Buyer pays: USDT
Network: Tron
Seller reporting currency: EUR
Product: One-year software license
Delivery: Automatic
Enter fullscreen mode Exit fullscreen mode
These values describe different parts of the transaction.
Do not compress them into one currency field.
What this product is
A Cross-Border Crypto Payment Stack is a merchant-facing system for selling digital products to international buyers.
It can include:
- localized product and checkout pages
- hosted or branded crypto checkout
- immutable order pricing
- configurable currencies and networks
- verified payment events
- automatic digital fulfillment
- buyer-facing payment status
- late-payment handling
- support investigation
- reconciliation
- finance exports
- optional partner payouts
The product is not:
- a new blockchain
- a crypto exchange
- a custodial wallet
- a legal or tax service
- a method for bypassing regional rules
- a guarantee that every buyer can pay from every country
The merchant remains responsible for determining where and under what conditions the product can be offered.
The developer provides the technical control layer.
How this differs from PaymentOps
A Crypto PaymentOps Service manages the internal operational lifecycle of payments.
This product packages that operational core for international digital selling.
Its cross-border responsibilities include:
Localized checkout
International buyer instructions
Pricing and payment currency separation
Regional merchant policies
Digital delivery
Buyer self-service status
Cross-border reporting fields
Enter fullscreen mode Exit fullscreen mode
PaymentOps is the operational foundation.
The Cross-Border Stack is the complete merchant and buyer experience built on top of it.
Choose one digital seller niche
Do not begin by building for every kind of digital business.
The workflow for a software license seller is different from the workflow for a course creator or consulting business.
This article uses:
Software license seller
Enter fullscreen mode Exit fullscreen mode
The complete outcome is:
Customer selects license
-> Order price is locked
-> Crypto invoice is created
-> Customer pays
-> Payment is verified
-> One license is assigned
-> Customer receives delivery
-> Seller receives an operational record
Enter fullscreen mode Exit fullscreen mode
The same core can later support other fulfillment adapters.
Examples:
Digital download
-> Generate signed download entitlement
Online course
-> Create enrollment
SaaS plan
-> Grant subscription term
Premium community
-> Grant membership access
Remote service
-> Create project and intake workflow
Enter fullscreen mode Exit fullscreen mode
Build one complete workflow before adding more product types.
Define the commercial transaction
Before creating a payment, create an authoritative local order.
The order should record:
- merchant
- customer
- product
- product version
- product price
- pricing currency
- checkout locale
- billing country when collected
- accepted terms version
- delivery type
- refund policy version
- timestamp
This creates a commercial snapshot.
A product price may change after checkout begins.
The existing order should not change with it.
Product catalog price today: 59 USD
Order price created yesterday: 49 USD
Enter fullscreen mode Exit fullscreen mode
The customer pays the order price, not the latest catalog price.
Separate the currency concepts
A cross-border payment stack may involve several currency values.
Pricing currency
The currency used to price the product.
49 USD
Enter fullscreen mode Exit fullscreen mode
Payment asset
The cryptocurrency sent by the customer.
USDT
BTC
LTC
Enter fullscreen mode Exit fullscreen mode
Payment network
The blockchain network used to send the asset.
Tron
Ethereum
Polygon
Solana
Enter fullscreen mode Exit fullscreen mode
Received asset
The asset recorded by the payment provider for the merchant payment.
Reporting currency
The currency the seller uses for internal reports.
USD
EUR
GBP
Enter fullscreen mode Exit fullscreen mode
Do not assume these are identical.
A practical order record may look like:
{
"order_value": "49.00",
"pricing_currency": "USD",
"payment_asset": "USDT",
"payment_network": "Tron",
"received_amount": "49.00",
"reporting_currency": "EUR"
}
Enter fullscreen mode Exit fullscreen mode
The reporting conversion should use a documented merchant policy and a stored rate snapshot.
Do not recalculate historical reports using today’s rate.
Recommended architecture
+-------------------------+
| International Storefront|
+------------+------------+
|
| Product and locale
v
+-------------------------+
| Order Service |
+------------+------------+
|
| Locked commercial order
v
+-------------------------+
| Checkout Service |
+------------+------------+
|
| Generate payment
v
+-------------------------+
| OxaPay |
+------------+------------+
|
| Signed callback
v
+-------------------------+
| Webhook Receiver |
+------------+------------+
|
| Verify and persist
v
+-------------------------+
| Event Store + Outbox |
+------------+------------+
|
v
+-------------------------+
| Payment Worker |
+------------+------------+
|
| Verify latest state
v
+-------------------------+
| Fulfillment Adapter |
+------------+------------+
|
+-----+-------------------+
| |
v v
License Delivery Manual Review
|
v
Customer Status / Support / Reporting
Enter fullscreen mode Exit fullscreen mode
The webhook request should not deliver the product directly.
It should validate and persist the event, create asynchronous work, and return quickly.
A cross-border data model
CREATE TABLE merchants (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
reporting_currency TEXT NOT NULL,
default_locale TEXT NOT NULL DEFAULT 'en-US',
timezone TEXT NOT NULL DEFAULT 'UTC',
support_email TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE products (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
sku TEXT NOT NULL,
name TEXT NOT NULL,
product_type TEXT NOT NULL,
fulfillment_type TEXT NOT NULL,
price NUMERIC(20, 8) NOT NULL,
pricing_currency TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (merchant_id, sku)
);
CREATE TABLE customers (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
email TEXT,
name TEXT,
locale TEXT,
billing_country TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE orders (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
customer_id UUID REFERENCES customers(id),
product_id UUID NOT NULL REFERENCES products(id),
order_number TEXT NOT NULL,
product_name_snapshot TEXT NOT NULL,
product_version_snapshot TEXT,
order_value NUMERIC(20, 8) NOT NULL,
pricing_currency TEXT NOT NULL,
checkout_locale TEXT NOT NULL,
billing_country TEXT,
fulfillment_type TEXT NOT NULL,
terms_version TEXT,
refund_policy_version TEXT,
order_status TEXT NOT NULL DEFAULT 'pending_payment',
fulfillment_status TEXT NOT NULL DEFAULT 'not_started',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
paid_at TIMESTAMP,
fulfilled_at TIMESTAMP,
UNIQUE (merchant_id, order_number)
);
CREATE TABLE payment_sessions (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
order_id UUID NOT NULL REFERENCES orders(id),
provider TEXT NOT NULL DEFAULT 'oxapay',
provider_order_id TEXT NOT NULL,
provider_track_id TEXT,
checkout_mode TEXT NOT NULL,
provider_status TEXT,
internal_status TEXT NOT NULL DEFAULT 'created',
requested_amount NUMERIC(20, 8) NOT NULL,
pricing_currency TEXT NOT NULL,
payment_asset TEXT,
payment_network TEXT,
received_amount NUMERIC(36, 18),
payment_url TEXT,
expires_at TIMESTAMP,
paid_at TIMESTAMP,
raw_provider_response JSONB,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (provider, provider_track_id),
UNIQUE (provider_order_id)
);
CREATE TABLE payment_events (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
payment_session_id UUID REFERENCES payment_sessions(id),
provider TEXT NOT NULL,
payload_hash TEXT NOT NULL,
provider_track_id TEXT,
provider_status TEXT,
raw_payload JSONB NOT NULL,
signature_valid BOOLEAN NOT NULL,
source TEXT NOT NULL,
received_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (merchant_id, payload_hash)
);
CREATE TABLE fulfillment_jobs (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
order_id UUID NOT NULL REFERENCES orders(id),
payment_session_id UUID NOT NULL REFERENCES payment_sessions(id),
fulfillment_type TEXT NOT NULL,
idempotency_key TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'queued',
attempt_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
next_attempt_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
completed_at TIMESTAMP
);
CREATE TABLE delivery_entitlements (
id UUID PRIMARY KEY,
order_id UUID NOT NULL REFERENCES orders(id),
payment_session_id UUID NOT NULL REFERENCES payment_sessions(id),
entitlement_type TEXT NOT NULL,
entitlement_reference TEXT,
status TEXT NOT NULL,
expires_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (payment_session_id)
);
CREATE TABLE reporting_snapshots (
id UUID PRIMARY KEY,
order_id UUID NOT NULL REFERENCES orders(id),
payment_session_id UUID NOT NULL REFERENCES payment_sessions(id),
order_value NUMERIC(20, 8) NOT NULL,
pricing_currency TEXT NOT NULL,
received_amount NUMERIC(36, 18),
received_asset TEXT,
reporting_currency TEXT NOT NULL,
reporting_value NUMERIC(20, 8),
conversion_rate NUMERIC(36, 18),
rate_source TEXT,
rate_timestamp TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (payment_session_id)
);
CREATE TABLE operational_cases (
id UUID PRIMARY KEY,
merchant_id UUID NOT NULL REFERENCES merchants(id),
order_id UUID REFERENCES 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,
evidence JSONB,
resolution_note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMP
);
CREATE TABLE outbox_jobs (
id UUID PRIMARY KEY,
topic TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempt_count INTEGER NOT NULL DEFAULT 0,
available_at TIMESTAMP NOT NULL DEFAULT NOW(),
published_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
Enter fullscreen mode Exit fullscreen mode
The separation between:
Order
Payment session
Payment event
Fulfillment job
Delivery entitlement
Reporting snapshot
Enter fullscreen mode Exit fullscreen mode
is essential.
Each represents a different business fact.
Create the order before the invoice
Never let the browser submit an authoritative price.
Load the product and price from your own database.
import crypto from "node:crypto";
export async function createDigitalOrder({
merchantId,
customer,
productId,
locale,
billingCountry,
acceptedTermsVersion,
}) {
const product = await db.product.findFirst({
where: {
id: productId,
merchantId,
active: true,
},
});
if (!product) {
throw new Error("Product is unavailable");
}
await assertMerchantCanSell({
merchantId,
billingCountry,
product,
});
return db.order.create({
data: {
merchantId,
customerId: customer.id,
productId: product.id,
orderNumber: createOrderNumber(),
productNameSnapshot: product.name,
productVersionSnapshot:
product.currentVersion ?? null,
orderValue: product.price,
pricingCurrency:
product.pricingCurrency,
checkoutLocale: locale,
billingCountry,
fulfillmentType:
product.fulfillmentType,
termsVersion: acceptedTermsVersion,
refundPolicyVersion:
product.refundPolicyVersion,
orderStatus: "pending_payment",
fulfillmentStatus: "not_started",
},
});
}
function createOrderNumber() {
return `ORD-${crypto
.randomUUID()
.replaceAll("-", "")
.slice(0, 12)
.toUpperCase()}`;
}
Enter fullscreen mode Exit fullscreen mode
assertMerchantCanSell applies merchant-configured regional and product policies.
It should not pretend to make a legal determination automatically.
Create an OxaPay hosted invoice
For the first version, hosted invoices are usually the safer option.
const OXAPAY_API =
"https://api.oxapay.com/v1";
export async function createHostedPayment({
merchant,
order,
customer,
}) {
const providerOrderId =
`digital_${order.id}`;
const session =
await db.paymentSession.create({
data: {
merchantId: merchant.id,
orderId: order.id,
provider: "oxapay",
providerOrderId,
checkoutMode: "invoice",
internalStatus: "created",
requestedAmount: order.orderValue,
pricingCurrency:
order.pricingCurrency,
},
});
try {
const merchantApiKey =
await decryptSecret(
merchant.oxapayMerchantApiKeyEncrypted,
);
const response = await fetch(
`${OXAPAY_API}/payment/invoice`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
merchant_api_key: merchantApiKey,
},
body: JSON.stringify({
amount: order.orderValue,
currency: order.pricingCurrency,
order_id: providerOrderId,
email: customer.email,
description:
`Digital order ${order.orderNumber}`,
callback_url:
`${process.env.APP_URL}/webhooks/oxapay/${merchant.webhookEndpointId}`,
return_url:
`${process.env.APP_URL}/orders/${order.orderNumber}/status`,
lifetime: 60,
sandbox:
process.env.NODE_ENV !==
"production",
}),
},
);
const payload = await response.json();
if (!response.ok) {
throw new Error(
payload?.error?.message ??
`Invoice creation failed with ${response.status}`,
);
}
const payment = payload.data;
return db.paymentSession.update({
where: { id: session.id },
data: {
providerTrackId:
String(payment.track_id),
providerStatus: "new",
internalStatus:
"invoice_created",
paymentUrl: payment.payment_url,
expiresAt: payment.expired_at
? new Date(
Number(payment.expired_at) *
1000,
)
: null,
rawProviderResponse: payload,
},
});
} catch (error) {
await db.paymentSession.update({
where: { id: session.id },
data: {
internalStatus:
"creation_failed",
},
});
throw error;
}
}
Enter fullscreen mode Exit fullscreen mode
The buyer returning to the return_url does not prove payment.
Only a verified payment event or a verified Payment Information lookup should move the order toward fulfillment.
Hosted invoice or white-label?
Use hosted invoices when
- launch speed matters
- the merchant accepts a redirect
- the main value is fulfillment and operations
- the seller wants less frontend complexity
- the first version is still validating demand
Use white-label when
- payment must remain inside the seller’s interface
- localized instructions are essential
- the merchant needs more branding control
- the checkout includes product-specific guidance
- the payment state page is part of the product
White-label checkout requires additional care.
The page must clearly display:
- payment asset
- network
- exact amount
- address
- QR code
- expiration time
- current payment state
- late-payment warning
OxaPay white-label addresses are valid only for their configured lifetime. The checkout must treat expiration as a hard boundary.
Do not encourage a customer to send funds after expiry.
Configure payment methods dynamically
Do not hardcode currency and network combinations.
OxaPay provides a Supported Currencies endpoint that returns cryptocurrency and network information.
A merchant configuration can define:
{
"preferred_methods": [
{
"currency": "USDT",
"networks": ["Tron", "Polygon"]
},
{
"currency": "LTC",
"networks": ["Litecoin"]
}
],
"hidden_methods": [],
"stablecoin_first": true
}
Enter fullscreen mode Exit fullscreen mode
Validate the merchant configuration against current provider-supported methods.
The checkout should not display a network combination the provider no longer supports.
Stablecoin-first is a UX policy
Some digital sellers prefer to present stablecoins first because the product itself is priced in fiat terms.
That can make the payment amount easier for buyers to understand.
It does not make the transaction risk-free.
The product should not claim:
Guaranteed value
Zero settlement risk
Universal availability
Enter fullscreen mode Exit fullscreen mode
Stablecoins still introduce network, issuer, liquidity, operational, and regional considerations.
Treat stablecoin-first as a configurable checkout preference.
Validate OxaPay callbacks
OxaPay signs payment callbacks using HMAC SHA-512 over the raw request body.
Use a merchant-specific webhook endpoint so the server knows which Merchant API Key should validate the request.
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post(
"/webhooks/oxapay/:endpointId",
express.raw({
type: "application/json",
}),
async (req, res) => {
const merchant =
await db.merchant.findUnique({
where: {
webhookEndpointId:
req.params.endpointId,
},
});
if (!merchant) {
return res
.status(404)
.send("unknown endpoint");
}
const rawBody = req.body;
const receivedHmac =
req.get("HMAC");
const merchantApiKey =
await decryptSecret(
merchant
.oxapayMerchantApiKeyEncrypted,
);
const expectedHmac = crypto
.createHmac(
"sha512",
merchantApiKey,
)
.update(rawBody)
.digest("hex");
if (
!safeEqualSha512(
receivedHmac,
expectedHmac,
)
) {
await recordRejectedCallback({
merchantId: merchant.id,
reason: "invalid_hmac",
});
return res
.status(401)
.send("invalid signature");
}
let payload;
try {
payload = JSON.parse(
rawBody.toString("utf8"),
);
} catch {
return res
.status(400)
.send("invalid json");
}
const payloadHash = crypto
.createHash("sha256")
.update(rawBody)
.digest("hex");
try {
await persistEventAndOutbox({
merchant,
payload,
payloadHash,
});
return res
.status(200)
.send("ok");
} catch (error) {
console.error(
"Payment event persistence failed",
error,
);
return res
.status(500)
.send("failed");
}
},
);
function safeEqualSha512(
received,
expected,
) {
const sha512Hex =
/^[a-f0-9]{128}$/i;
if (
!received ||
!expected ||
!sha512Hex.test(received) ||
!sha512Hex.test(expected)
) {
return false;
}
return crypto.timingSafeEqual(
Buffer.from(received, "hex"),
Buffer.from(expected, "hex"),
);
}
Enter fullscreen mode Exit fullscreen mode
The payment event and outbox job should be written inside one database transaction.
Store event
+
Create processing job
+
Commit
Enter fullscreen mode Exit fullscreen mode
If the queue is temporarily unavailable, the outbox dispatcher can retry publishing later.
Do not fulfill on paying
OxaPay payment callbacks may first report:
paying
Enter fullscreen mode Exit fullscreen mode
Then later:
paid
Enter fullscreen mode Exit fullscreen mode
Map these states into buyer-facing language.
function mapPaymentStatus(status) {
const normalized = String(
status ?? "",
)
.trim()
.toLowerCase();
const statuses = {
new: "created",
waiting: "waiting",
paying: "confirming",
paid: "paid",
manual_accept:
"manually_accepted",
underpaid: "underpaid",
expired: "expired",
refunding:
"refund_in_progress",
refunded: "refunded",
};
return statuses[normalized] ??
"unknown";
}
Enter fullscreen mode Exit fullscreen mode
For paying, show:
Payment detected. Waiting for completion.
Do not send another payment.
Enter fullscreen mode Exit fullscreen mode
Do not deliver the product yet.
For paid, the fulfillment worker can begin after verifying the order and provider state.
Verify before digital delivery
Before assigning an irreversible entitlement, retrieve Payment Information.
async function fetchOxaPayPayment({
merchantApiKey,
trackId,
}) {
const response = await fetch(
`${OXAPAY_API}/payment/${encodeURIComponent(trackId)}`,
{
method: "GET",
headers: {
"Content-Type":
"application/json",
merchant_api_key:
merchantApiKey,
},
},
);
const payload = await response.json();
if (!response.ok) {
throw new Error(
payload?.error?.message ??
`Payment lookup failed with ${response.status}`,
);
}
return payload.data;
}
function assertPaymentMatchesOrder({
payment,
session,
order,
}) {
if (
String(payment.status)
.toLowerCase() !== "paid"
) {
throw new PermanentDeliveryError(
"Payment is not paid",
);
}
if (
String(payment.order_id) !==
String(session.providerOrderId)
) {
throw new PermanentDeliveryError(
"Provider order_id mismatch",
);
}
if (
decimal(payment.amount).lessThan(
decimal(order.orderValue),
)
) {
throw new PermanentDeliveryError(
"Payment amount does not satisfy order",
);
}
}
Enter fullscreen mode Exit fullscreen mode
Use decimal arithmetic for payment values.
Do not use ordinary floating-point comparisons.
Deliver a software license exactly once
The same payment must not assign two licenses.
export async function fulfillLicenseOrder({
orderId,
paymentSessionId,
}) {
const idempotencyKey = [
orderId,
paymentSessionId,
"assign_license",
].join(":");
const existingJob =
await db.fulfillmentJob.findUnique({
where: { idempotencyKey },
});
if (
existingJob?.status === "completed"
) {
return existingJob;
}
const job =
existingJob ??
(await db.fulfillmentJob.create({
data: {
merchantId:
order.merchantId,
orderId,
paymentSessionId,
fulfillmentType:
"software_license",
idempotencyKey,
status: "running",
attemptCount: 1,
},
}));
try {
const license =
await reserveLicenseKey({
productId: order.productId,
orderId,
idempotencyKey,
});
const entitlement =
await db.deliveryEntitlement.upsert({
where: {
paymentSessionId,
},
create: {
orderId,
paymentSessionId,
entitlementType:
"software_license",
entitlementReference:
license.id,
status: "active",
},
update: {},
});
await sendLicenseDelivery({
customerEmail:
order.customer.email,
orderNumber:
order.orderNumber,
licenseKey: license.key,
locale:
order.checkoutLocale,
idempotencyKey:
`${idempotencyKey}:email`,
});
await db.$transaction([
db.order.update({
where: { id: orderId },
data: {
orderStatus: "completed",
fulfillmentStatus:
"delivered",
fulfilledAt: new Date(),
},
}),
db.fulfillmentJob.update({
where: { id: job.id },
data: {
status: "completed",
completedAt: new Date(),
},
}),
]);
return entitlement;
} catch (error) {
await recordFulfillmentFailure({
jobId: job.id,
error,
});
throw error;
}
}
Enter fullscreen mode Exit fullscreen mode
Separate license assignment from email delivery.
If the email provider fails, retry the email.
Do not assign another license.
Other fulfillment adapters
Use a shared adapter interface.
const fulfillmentAdapters = {
software_license:
fulfillLicenseOrder,
digital_download:
fulfillDownloadOrder,
course_access:
fulfillCourseOrder,
saas_plan:
fulfillSaaSOrder,
community_access:
fulfillCommunityOrder,
manual_service:
fulfillServiceOrder,
};
Enter fullscreen mode Exit fullscreen mode
Each adapter should define:
- required input
- idempotency key
- success evidence
- retryable failures
- permanent failures
- customer message
- support escalation
A generic fulfilled = true field is not enough.
Make delivery links temporary
For downloadable products, do not email a permanent public file URL.
Create a delivery entitlement and generate short-lived signed links.
Payment confirmed
-> Download entitlement created
-> Customer opens status page
-> Signed link generated
-> Link expires
-> Entitlement remains valid under merchant policy
Enter fullscreen mode Exit fullscreen mode
This allows the seller to:
- limit link lifetime
- regenerate links
- track downloads
- revoke access after a refund
- avoid exposing storage URLs
The entitlement is the durable access record.
The signed URL is temporary delivery infrastructure.
Build a localized customer status page
Give every order a persistent status page:
/orders/ORD-2026-1042/status
Enter fullscreen mode Exit fullscreen mode
Show:
- product name
- order number
- order value
- pricing currency
- selected payment asset and network
- invoice expiry
- payment state
- delivery state
- support contact
- next customer action
Useful states include:
Waiting for payment
Complete the payment before the displayed expiration time.
Enter fullscreen mode Exit fullscreen mode
Payment detected
We detected payment activity. Do not send another payment while it is being processed.
Enter fullscreen mode Exit fullscreen mode
Payment confirmed
Your payment is confirmed. We are preparing your product.
Enter fullscreen mode Exit fullscreen mode
Delivered
Your product is ready. Check your email or use the secure delivery option below.
Enter fullscreen mode Exit fullscreen mode
Expired
This payment session has expired. Do not send funds using the expired payment details.
Enter fullscreen mode Exit fullscreen mode
Under review
We found a payment issue that requires review. You do not need to send another payment.
Enter fullscreen mode Exit fullscreen mode
Translate business meaning, not raw provider terminology.
Localize the high-friction content first
A complete multilingual application is not necessary for the MVP.
Start with the text most likely to cause payment problems:
- checkout instructions
- network warnings
- expiry warning
- payment-detected message
- delivery expectations
- refund policy
- support instructions
Do not rely only on machine translation for legal or policy text.
Store which policy version and locale the customer saw.
terms_version = 2026-07-en
refund_policy_version = 2026-06-de
Enter fullscreen mode Exit fullscreen mode
This creates a clearer operational record.
Use UTC internally
Store operational timestamps in UTC:
- order creation
- invoice expiration
- payment detection
- payment confirmation
- fulfillment
- refund
- support actions
Display them in the customer’s or merchant’s selected time zone.
Do not store ambiguous local timestamps without an offset.
Cross-border support becomes much harder when three systems show different local times.
Apply regional policies before creating payment
A merchant may need to disable:
- specific billing countries
- specific products in some regions
- selected payment methods
- selected networks
- fulfillment types
- manual-service orders
Implement merchant-configurable rules.
async function assertMerchantCanSell({
merchantId,
billingCountry,
product,
}) {
const policy =
await loadRegionalPolicy({
merchantId,
billingCountry,
productId: product.id,
});
if (policy?.salesDisabled) {
throw new CheckoutPolicyError(
"This product is unavailable for the selected region",
);
}
}
Enter fullscreen mode Exit fullscreen mode
This is an enforcement mechanism for merchant policy.
It is not a substitute for legal review.
Do not market IP blocking as complete compliance.
IP location can be inaccurate and should not be the only business signal.
Treat late payments as exceptions
Late payments create one of the hardest buyer-support scenarios.
Possible sequence:
Invoice expires
-> Customer sends payment using old details
-> New invoice is created
-> Customer may now have two payment attempts
Enter fullscreen mode Exit fullscreen mode
The system should:
- Preserve the expired session.
- Keep the order unresolved.
- Prevent automatic duplicate fulfillment.
- Search Payment Information and Payment History.
- Create a late-payment case.
- Let an authorized operator decide the outcome.
- Record the decision.
Never tell a customer to pay again before checking an existing payment claim.
For white-label payment addresses, the customer must be clearly warned not to send funds after expiration.
Handle duplicate payments deliberately
A buyer may pay two sessions for the same order.
Do not automatically deliver twice.
Create a case:
duplicate_payment_for_order
Enter fullscreen mode Exit fullscreen mode
Evidence should include:
- both
track_idvalues - paid amounts
- payment assets
- networks
- transaction hashes
- creation times
- payment times
- fulfillment state
The merchant policy may allow:
- refund review
- account credit
- second entitlement
- manual resolution
The software should expose the duplicate.
It should not invent the merchant decision.
Recover missed events with Payment History
Webhooks are the real-time path.
They should not be the only path.
A recovery job can query OxaPay Payment History using an overlapping time window.
export async function backfillRecentPayments({
merchant,
fromDate,
toDate,
}) {
const merchantApiKey =
await decryptSecret(
merchant.oxapayMerchantApiKeyEncrypted,
);
const params = new URLSearchParams({
from_date: String(
Math.floor(
fromDate.getTime() / 1000,
),
),
to_date: String(
Math.floor(
toDate.getTime() / 1000,
),
),
page: "1",
size: "100",
});
const response = await fetch(
`${OXAPAY_API}/payment?${params}`,
{
method: "GET",
headers: {
"Content-Type":
"application/json",
merchant_api_key:
merchantApiKey,
},
},
);
const payload = await response.json();
if (!response.ok) {
throw new Error(
payload?.error?.message ??
`Payment history failed with ${response.status}`,
);
}
for (
const payment of
extractPaymentItems(payload)
) {
await reconcileProviderPayment({
merchant,
payment,
source:
"payment_history_backfill",
});
}
}
Enter fullscreen mode Exit fullscreen mode
The exact response object should be mapped from the current API response and stored as raw evidence.
Use overlapping windows because jobs can fail or provider records can appear later.
Reconcile business outcomes
The Cross-Border Stack does not need to duplicate the full Payment Reconciliation Tool.
It should integrate the core checks required for digital selling.
Examples:
Provider payment paid
Order still pending
Order paid
Delivery not completed
Delivery completed
No confirmed provider payment
Payment paid
Reporting snapshot missing
Payment paid twice
One order
Expired white-label session
Customer claims payment
Refunded payment
License still active
Enter fullscreen mode Exit fullscreen mode
The main operational view should be:
Needs attention
Enter fullscreen mode Exit fullscreen mode
not another long transaction table.
Build support around evidence
A Crypto Payment Support Desk can provide the complete agent interface.
The Cross-Border Stack should at minimum let support search by:
- order number
- customer email
track_id- transaction hash
- product
- payment asset
- network
- payment status
- delivery status
- creation date
A readable timeline might show:
12:01 UTC Order created
12:01 UTC Invoice generated
12:05 UTC Payment detected
12:07 UTC Payment confirmed
12:07 UTC License allocation started
12:08 UTC License assigned
12:08 UTC Delivery email failed
12:09 UTC Retry scheduled
12:12 UTC Email delivered
Enter fullscreen mode Exit fullscreen mode
This tells support whether the issue belongs to:
- checkout
- payment
- order matching
- fulfillment
- email delivery
- customer action
Use customer-safe support templates
Payment detected
We detected payment activity for your order. The payment is still being processed, so please do not send another transaction.
Enter fullscreen mode Exit fullscreen mode
Paid but not delivered
Your payment has been confirmed. Product delivery did not complete automatically, and our team is reviewing the order. You do not need to pay again.
Enter fullscreen mode Exit fullscreen mode
Expired payment session
This payment session has expired. Do not send funds using the expired payment details. Create a new payment request or contact support if you already sent a transaction.
Enter fullscreen mode Exit fullscreen mode
Payment cannot be matched
We could not safely match the available information to your order. Please send the transaction hash, payment asset, network, amount, and approximate payment time.
Enter fullscreen mode Exit fullscreen mode
Do not tell a customer that funds are lost, recoverable, or refundable until the evidence and merchant policy support that conclusion.
Build reporting records at payment time
Finance reports should not reconstruct historical facts from live product data.
Create a reporting snapshot when the payment is accepted.
Record:
- order value
- pricing currency
- payment amount
- payment asset
- network
- provider
track_id - transaction hash
- reporting currency
- conversion rate
- conversion source
- rate timestamp
- customer billing country when collected
- product and SKU
- refund state
- fulfillment state
The reporting snapshot is not a tax return.
It is a clean operational export that the merchant can provide to its accounting process.
Optional payouts belong in a separate module
Some digital sellers pay:
- affiliates
- contractors
- instructors
- creators
- referral partners
Do not trigger payouts directly from the customer payment webhook.
Use:
Confirmed revenue
-> Ledger entry
-> Partner balance
-> Payout request
-> Approval
-> OxaPay Payout API
-> Payout status tracking
Enter fullscreen mode Exit fullscreen mode
The Revenue Split and Payout System covers this architecture in more depth.
Payouts require:
- separate Payout API Key
- destination controls
- approval permissions
- immutable ledger entries
- payout limits
- audit history
- reconciliation
The first Cross-Border Stack MVP does not need payout automation.
Security baseline
Before selling the stack, implement:
- HTTPS-only callbacks
- HMAC validation over the raw body
- timing-safe signature comparison
- encrypted Merchant API Keys
- separate Payout API Keys
- strict tenant isolation
- event deduplication
- action-level idempotency
- transactional outbox
- background fulfillment jobs
- decimal financial calculations
- signed download links
- encrypted or masked license data
- role-based admin access
- audit logs for manual actions
- secret masking in logs
- rate-limited status and support endpoints
- Payment Information verification
- Payment History recovery
- alerting for paid-but-not-delivered orders
Payment success without delivery success must create a visible incident.
Compliance and policy boundaries
Do not position the product as:
Payments without rules
Guaranteed global availability
A way around payment restrictions
Risk-free stablecoin settlement
Automatic legal compliance
Enter fullscreen mode Exit fullscreen mode
A responsible product can provide:
- merchant-configurable regional rules
- disabled-country settings
- product availability controls
- policy-version tracking
- consent records
- refund-policy display
- manual-review flags
- exports
- audit logs
- customer-support records
The merchant and qualified professionals determine the applicable tax, legal, sanctions, consumer-protection, and refund requirements.
Your system enforces the resulting technical policies.
The MVP
Build one complete flow for one digital seller niche.
For a software license seller:
Product selected
-> Authoritative order created
-> Hosted OxaPay invoice generated
-> Payment callback verified
-> Payment Information checked
-> One license assigned
-> Delivery email sent
-> Customer status updated
-> Reporting snapshot created
Enter fullscreen mode Exit fullscreen mode
The MVP should include:
- one merchant
- product catalog
- localized checkout copy
- local order creation
- hosted OxaPay invoice
- payment-session storage
- HMAC-validated webhook
- event and outbox storage
- Payment Information verification
- idempotent license assignment
- customer status page
- support search
- needs-attention queue
- Payment History backfill
- CSV export
Do not include in version one:
- every digital product niche
- dozens of languages
- automated partner payouts
- multiple payment providers
- full accounting
- tax calculation
- complex discount engines
- custom treasury management
- visual automation builders
Prove the payment-to-delivery workflow first.
Production expansion
After the first seller proves demand, add:
- white-label checkout
- payment-method controls
- additional locales
- additional fulfillment adapters
- static-address prepaid balances
- agency merchant workspaces
- finance integrations
- configurable refund workflows
- team permissions
- incident alerts
- SLA reporting
- partner payout module
- merchant-facing API
- Make or n8n integrations
Expansion should follow repeated merchant requirements.
Do not add features because they sound like part of a payment platform.
Product positioning
Weak positioning:
Accept crypto payments internationally.
Better positioning:
Crypto checkout for digital sellers.
Stronger positioning:
A cross-border crypto payment stack that connects international checkout to verified digital delivery, customer status, support, recovery, and finance-ready records.
Niche-specific positioning is even stronger:
Cross-border crypto checkout for software license sellers, with verified payment events, one-time license delivery, localized buyer instructions, and paid-but-not-delivered recovery.
The merchant is not purchasing access to an endpoint.
They are purchasing a reliable international order outcome.
What makes this a real product?
A basic integration says:
Create crypto invoice
-> Wait for paid
-> Send product
Enter fullscreen mode Exit fullscreen mode
A production Cross-Border Stack says:
Create authoritative commercial order
-> Preserve pricing and policy snapshot
-> Apply merchant regional rules
-> Generate supported payment session
-> Explain asset, network, and expiry
-> Validate signed callback
-> Preserve payment evidence
-> Verify latest provider state
-> Deliver product idempotently
-> Expose customer status
-> Recover missed events
-> Reconcile payment and delivery
-> Create historical reporting record
-> Surface every unresolved exception
Enter fullscreen mode Exit fullscreen mode
That is the difference between a payment button and international digital commerce infrastructure.
Final takeaway
Digital sellers do not only need a way to receive crypto from another country.
They need a reliable path from international buyer intent to completed product delivery.
OxaPay provides the payment primitives:
- hosted invoices
- white-label payment details
- static addresses
- unique
track_idreferences - Merchant
order_idreferences - HMAC-signed payment callbacks
- Payment Information
- Payment History
- supported currency and network data
- optional payout APIs
The developer builds the cross-border operating layer:
- localized checkout
- authoritative orders
- currency separation
- network instructions
- regional merchant policies
- verified fulfillment
- customer visibility
- support evidence
- reconciliation
- finance-ready records
Start with one seller type.
Price one product clearly.
Accept one verified payment.
Deliver one digital entitlement exactly once.
Make every exception visible.
That is how international crypto checkout becomes a merchant product rather than another API integration.
Which niche would you start with: software licenses, digital downloads, courses, SaaS products, or remote services?
Related articles
- 10 Crypto Payment Products Developers Can Build for Merchants
- Build a Crypto PaymentOps Service for Merchants
- Build a Vertical Crypto Checkout for Hosting Providers
- Build a Telegram Paid Access System with Crypto Payments
- Build a Crypto Revenue Split and Payout System for Merchants
- Build a Crypto Payment Reconciliation Tool for Merchants
- Build a Payment Automation Studio for Crypto Merchants
- Build a Merchant Crypto Launch Kit for Agencies
- Build a Crypto Payment Module for SaaS Apps
- Build a Crypto Payment Support Desk
References
OxaPay
- OxaPay Generate Invoice
- OxaPay Generate White Label
- OxaPay Generate Static Address
- OxaPay Payment Information
- OxaPay Payment History
- OxaPay Payment Status Table
- OxaPay Webhook
- OxaPay Supported Currencies
- OxaPay Generate Payout
- OxaPay Payout Information
- OxaPay Payout History
- OxaPay Payout Status Table
답글 남기기