디지털 판매자를 위한 국경 간 암호화 결제 스택 구축

작성자

카테고리:

← 피드로
DEV Community · kevin.s · 2026-07-26 개발(SW)

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:

  1. Preserve the expired session.
  2. Keep the order unresolved.
  3. Prevent automatic duplicate fulfillment.
  4. Search Payment Information and Payment History.
  5. Create a late-payment case.
  6. Let an authorized operator decide the outcome.
  7. 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_id values
  • 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_id references
  • Merchant order_id references
  • 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

References

OxaPay

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다