DEV Community

kevin.s
kevin.s

Posted on • Edited on

Build a Vertical Crypto Checkout for Hosting Providers

A customer pays a hosting invoice.

The payment provider reports paid, but the VPS is not provisioned.

Another customer renews an active server. The payment succeeds, but the service expiry date does not change.

A third customer sends funds after the checkout expires and opens a support ticket with a transaction screenshot.

The checkout accepted payment.

The hosting workflow did not complete.

This is why a vertical checkout is more than a payment page.

A vertical crypto checkout connects payment infrastructure to the operational rules of one merchant category. For hosting providers, that means invoice creation, payment verification, account provisioning, service renewal, expiry handling, customer instructions, support visibility, and recovery.

This article uses OxaPay as the payment infrastructure reference, but the architecture is provider-agnostic.

This article is part of 10 Crypto Payment Products Developers Can Build for Merchants.

What makes a checkout vertical?

A generic checkout knows:

Order
Amount
Payment status
Enter fullscreen mode Exit fullscreen mode

A hosting checkout understands:

Hosting invoice
Customer account
Service or server
Plan
Billing period
New order or renewal
Provisioning state
Current service expiry
Support ownership
Enter fullscreen mode Exit fullscreen mode

That context changes what should happen after payment.

For a new hosting order:

Payment confirmed
-> Provision service
-> Create customer credentials
-> Activate billing record
-> Send onboarding email
Enter fullscreen mode Exit fullscreen mode

For a renewal:

Payment confirmed
-> Extend the existing service period
-> Update the billing panel
-> Send renewal confirmation
Enter fullscreen mode Exit fullscreen mode

For an upgrade:

Payment confirmed
-> Validate upgrade eligibility
-> Change service resources
-> Record the new plan
Enter fullscreen mode Exit fullscreen mode

The payment primitive may be identical in all three cases.

The business action is not.

A vertical checkout is therefore:

A payment flow designed around one industry's order, fulfillment, renewal, support, and recovery rules.

Why hosting is a strong vertical

Hosting providers already have structured operational workflows.

They normally manage:

  • customer accounts
  • service plans
  • billing periods
  • invoices
  • provisioning
  • renewals
  • suspensions
  • support tickets
  • service expiry
  • server credentials

Payment state directly affects service state.

That makes the outcome easy to define:

Customer pays
-> Hosting service starts or continues
Enter fullscreen mode Exit fullscreen mode

Hosting also creates recurring operational scenarios:

  • new service purchases
  • monthly or annual renewals
  • upgrades
  • overdue invoices
  • expired payment sessions
  • duplicate payment attempts
  • paid but unprovisioned orders
  • service extensions that fail
  • payment-related support tickets

A generic payment integration can receive the money.

A hosting-specific checkout controls what happens around it.

Do you need a custom product?

OxaPay already provides plugins for hosting and billing platforms such as WHMCS, WISECP, Blesta, and Clientexec.

Use an existing plugin when the merchant only needs:

  • standard crypto payment acceptance
  • normal billing-panel invoice updates
  • provider-hosted checkout
  • minimal customization
  • no additional operational workflow

Do not build custom infrastructure merely to reproduce a plugin.

A vertical checkout becomes useful when the merchant needs something beyond the standard integration:

  • custom payment UX
  • multiple hosting or billing systems
  • specialized provisioning
  • customer-facing payment status
  • merchant-specific renewal policies
  • support timelines
  • paid-but-not-provisioned detection
  • recovery from missed callbacks
  • agency or reseller workflows
  • consolidated reporting
  • controlled manual actions

The product must solve a hosting problem that the existing plugin does not solve well enough.

Define the checkout contract

Before writing code, define what the system guarantees.

For a hosting checkout, useful invariants include:

Every payment session belongs to one merchant and one hosting order.

Every confirmed payment maps to a known order or enters manual review.

A service is never provisioned from a weak payment state.

The same payment cannot provision or renew a service twice.

A paid order is either fulfilled or visible in a needs-attention queue.

Every renewal creates an auditable service-period record.

A failed provisioning action does not change the confirmed payment state.

Every manual resolution records who performed it and why.
Enter fullscreen mode Exit fullscreen mode

These rules are more important than the checkout page design.

They determine whether the product can be trusted with real hosting orders.

Keep payment and service states separate

Do not store the entire workflow in one field such as:

status = paid
Enter fullscreen mode Exit fullscreen mode

A hosting order has several independent states.

Payment state

created
waiting
confirming
paid
underpaid
expired
refunded
Enter fullscreen mode Exit fullscreen mode

Order state

pending_payment
paid
processing
completed
cancelled
manual_review
Enter fullscreen mode Exit fullscreen mode

Fulfillment state

not_started
queued
running
completed
failed
needs_attention
Enter fullscreen mode Exit fullscreen mode

Service state

pending
provisioning
active
suspended
terminated
Enter fullscreen mode Exit fullscreen mode

This combination is valid:

Payment: paid
Order: processing
Fulfillment: failed
Service: pending
Enter fullscreen mode Exit fullscreen mode

It means the payment succeeded, but the merchant still owes the customer a hosting service.

Do not reverse the payment state because provisioning failed.

Create an operational exception and retry or escalate fulfillment.

Hosted invoice or white-label checkout?

OxaPay supports both hosted invoices and white-label payment creation.

The right option depends on what part of the experience creates value.

Hosted invoice

The flow is:

Hosting order created
-> Backend creates OxaPay invoice
-> Customer opens payment_url
-> OxaPay displays the payment interface
-> Webhook reports payment status
-> Hosting workflow continues
Enter fullscreen mode Exit fullscreen mode

Hosted invoices are usually the better first version when:

  • launch speed matters
  • the merchant accepts an external payment page
  • the main value exists after payment
  • you want less checkout UI complexity
  • you are validating demand

The developer still owns:

  • hosting order creation
  • track_id mapping
  • payment status page
  • webhook processing
  • provisioning
  • renewal
  • support visibility
  • recovery

White-label checkout

The flow is:

Customer opens merchant checkout
-> Backend requests white-label payment details
-> Merchant UI displays amount, address, network, QR, and expiry
-> Customer sends payment
-> Webhook reports payment state
-> Checkout updates in real time
-> Hosting workflow continues
Enter fullscreen mode Exit fullscreen mode

White-label is useful when:

  • the checkout must remain inside the merchant brand
  • the merchant needs hosting-specific instructions
  • network selection requires additional explanation
  • the product includes a custom status page
  • checkout UX is part of the commercial value

White-label also creates more responsibility.

Your application must display:

  • the exact payment amount
  • payment currency
  • blockchain network
  • payment address
  • QR code
  • expiry time
  • payment status
  • clear late-payment warnings

Treat expiry as a hard operational boundary. Do not hide it in small text.

The architecture

A production hosting checkout should separate payment ingestion from provisioning.

+----------------------+
| Hosting Store /      |
| Billing Panel        |
+----------+-----------+
           |
           | Create hosting order
           v
+----------------------+
| Vertical Checkout API|
+----------+-----------+
           |
           | Generate invoice
           v
+----------------------+
| OxaPay               |
+----------+-----------+
           |
           | Payment webhook
           v
+----------------------+
| Webhook Receiver     |
+----------+-----------+
           |
           | Verify and persist
           v
+----------------------+
| Payment Event Store  |
+----------+-----------+
           |
           v
+----------------------+
| Transactional Outbox |
+----------+-----------+
           |
           v
+----------------------+
| Job Queue            |
+----------+-----------+
           |
      +----+------------------+
      |                       |
      v                       v
Provisioning Worker      Renewal Worker
      |                       |
      +-----------+-----------+
                  |
                  v
        +-------------------+
        | Hosting Platform  |
        +---------+---------+
                  |
                  v
        +-------------------+
        | Outcome Verify    |
        +---------+---------+
                  |
                  v
        Dashboard / Support
Enter fullscreen mode Exit fullscreen mode

The Webhook Receiver should not provision a server or extend a service directly.

It should:

  1. Validate the callback.
  2. Store the payment event.
  3. Create an outbox job.
  4. Return the expected response quickly.

Provisioning belongs in a background worker where retries, timeouts, and partial failures can be controlled.

A hosting-specific data model

The data model should know what the customer purchased.

CREATE TABLE hosting_orders (
  id UUID PRIMARY KEY,
  merchant_id UUID NOT NULL,
  external_order_id TEXT NOT NULL,
  customer_id TEXT NOT NULL,
  service_id TEXT,
  plan_id TEXT NOT NULL,
  order_type TEXT NOT NULL,
  billing_period_months INTEGER NOT NULL,
  expected_amount NUMERIC(20, 8) NOT NULL,
  expected_currency TEXT NOT NULL,
  order_status TEXT NOT NULL DEFAULT 'pending_payment',
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
  UNIQUE (merchant_id, external_order_id)
);

CREATE TABLE payment_sessions (
  id UUID PRIMARY KEY,
  merchant_id UUID NOT NULL,
  hosting_order_id UUID NOT NULL REFERENCES hosting_orders(id),
  provider TEXT NOT NULL DEFAULT 'oxapay',
  provider_track_id TEXT NOT NULL,
  payment_method TEXT NOT NULL,
  provider_status TEXT NOT NULL,
  internal_status TEXT NOT NULL,
  requested_amount NUMERIC(20, 8) NOT NULL,
  requested_currency TEXT NOT NULL,
  payment_url TEXT,
  expires_at TIMESTAMP,
  paid_at TIMESTAMP,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
  UNIQUE (merchant_id, provider, provider_track_id)
);

CREATE TABLE payment_events (
  id UUID PRIMARY KEY,
  merchant_id UUID NOT NULL,
  payment_session_id UUID,
  payload_hash TEXT NOT NULL,
  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,
  hosting_order_id UUID NOT NULL REFERENCES hosting_orders(id),
  payment_session_id UUID NOT NULL REFERENCES payment_sessions(id),
  action_type TEXT NOT NULL,
  idempotency_key TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'queued',
  attempts INTEGER NOT NULL DEFAULT 0,
  last_error TEXT,
  next_attempt_at TIMESTAMP,
  completed_at TIMESTAMP,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  UNIQUE (idempotency_key)
);

CREATE TABLE service_period_grants (
  id UUID PRIMARY KEY,
  merchant_id UUID NOT NULL,
  service_id TEXT NOT NULL,
  hosting_order_id UUID NOT NULL REFERENCES hosting_orders(id),
  payment_session_id UUID NOT NULL REFERENCES payment_sessions(id),
  starts_at TIMESTAMP NOT NULL,
  ends_at TIMESTAMP NOT NULL,
  months_granted INTEGER NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  UNIQUE (payment_session_id)
);

CREATE TABLE operational_cases (
  id UUID PRIMARY KEY,
  merchant_id UUID NOT NULL,
  hosting_order_id UUID,
  payment_session_id UUID,
  case_type TEXT NOT NULL,
  severity TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'open',
  summary TEXT NOT NULL,
  recommended_action TEXT,
  resolution_note TEXT,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  resolved_at TIMESTAMP
);
Enter fullscreen mode Exit fullscreen mode

The order_type can identify:

new_service
renewal
upgrade
Enter fullscreen mode Exit fullscreen mode

The service_period_grants table prevents the same payment from extending a service twice.

Do not merely update an expiry date without recording why it changed.

Create the hosted checkout session

The merchant creates the hosting order first.

Your backend then creates the payment session and stores the returned track_id.

const OXAPAY_API = "https://api.oxapay.com/v1";

export async function createHostingCheckout({
  merchant,
  order,
  webhookEndpointId,
}) {
  const callbackUrl = [
    process.env.APP_URL,
    "webhooks",
    "oxapay",
    webhookEndpointId,
  ].join("/");

  const response = await fetch(
    `${OXAPAY_API}/payment/invoice`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        merchant_api_key: await decryptSecret(
          merchant.oxapayMerchantApiKeyEncrypted,
        ),
      },
      body: JSON.stringify({
        amount: order.expectedAmount,
        currency: order.expectedCurrency,
        order_id: order.externalOrderId,
        email: order.customerEmail,
        description: buildHostingDescription(order),
        callback_url: callbackUrl,
        return_url:
          `${process.env.APP_URL}/checkout/${order.id}/status`,
        lifetime: 60,
        sandbox: process.env.NODE_ENV !== "production",
      }),
    },
  );

  const payload = await response.json();

  if (!response.ok) {
    throw new Error(
      payload?.error?.message ??
        `OxaPay invoice creation failed with ${response.status}`,
    );
  }

  const payment = payload.data;

  return db.paymentSession.create({
    data: {
      merchantId: merchant.id,
      hostingOrderId: order.id,
      provider: "oxapay",
      providerTrackId: String(payment.track_id),
      paymentMethod: "invoice",
      providerStatus: "new",
      internalStatus: "created",
      requestedAmount: order.expectedAmount,
      requestedCurrency: order.expectedCurrency,
      paymentUrl: payment.payment_url,
      expiresAt: payment.expired_at
        ? new Date(Number(payment.expired_at) * 1000)
        : null,
    },
  });
}

function buildHostingDescription(order) {
  if (order.orderType === "renewal") {
    return `Renewal for service ${order.serviceId}`;
  }

  if (order.orderType === "upgrade") {
    return `Upgrade for service ${order.serviceId}`;
  }

  return `New hosting order ${order.externalOrderId}`;
}
Enter fullscreen mode Exit fullscreen mode

Never expose the Merchant API Key to frontend code.

The frontend receives only the checkout information it needs:

{
  "checkout_id": "chk_123",
  "payment_url": "https://...",
  "expires_at": "2026-07-28T10:00:00Z",
  "status_url": "/checkout/chk_123/status"
}
Enter fullscreen mode Exit fullscreen mode

Identify the merchant before validating the webhook

A multi-merchant system needs the correct Merchant API Key to validate the callback.

Do not trust the unverified payload to select that key.

Give each merchant integration a unique public endpoint ID:

/webhooks/oxapay/{endpoint_id}
Enter fullscreen mode Exit fullscreen mode

The endpoint ID identifies the merchant configuration.

It is not an API key and does not grant access to the dashboard.

Validate and persist the callback

OxaPay signs payment callbacks using HMAC SHA-512 over the raw request body. The signature is sent in the HMAC header.

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 endpoint = await db.webhookEndpoint.findUnique({
      where: {
        publicId: req.params.endpointId,
      },
      include: {
        merchant: true,
      },
    });

    if (!endpoint || !endpoint.enabled) {
      return res.status(404).send("unknown endpoint");
    }

    const rawBody = req.body;
    const receivedHmac = req.get("HMAC");

    const merchantApiKey = await decryptSecret(
      endpoint.merchant.oxapayMerchantApiKeyEncrypted,
    );

    const expectedHmac = crypto
      .createHmac("sha512", merchantApiKey)
      .update(rawBody)
      .digest("hex");

    if (!safeEqualSha512(receivedHmac, expectedHmac)) {
      await recordRejectedCallback({
        merchantId: endpoint.merchantId,
        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 persistPaymentEventAndOutbox({
        merchantId: endpoint.merchantId,
        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 storage operation should use one database transaction:

Insert payment event
+
Insert outbox job
+
Commit
Enter fullscreen mode Exit fullscreen mode

A separate dispatcher publishes the outbox job to the fulfillment queue.

This prevents a valid callback from being stored without ever reaching the worker.

Normalize payment state

Do not let provider-specific strings spread across the hosting application.

const PAYMENT_STATUS_MAP = {
  new: "created",
  waiting: "waiting",
  paying: "confirming",
  paid: "paid",
  manual_accept: "manually_accepted",
  underpaid: "underpaid",
  expired: "expired",
  refunding: "refund_in_progress",
  refunded: "refunded",
};

function normalizeOxaPayStatus(status) {
  const providerStatus = String(status ?? "")
    .trim()
    .toLowerCase();

  return {
    providerStatus,
    internalStatus:
      PAYMENT_STATUS_MAP[providerStatus] ?? "unknown",
  };
}
Enter fullscreen mode Exit fullscreen mode

Do not provision on paying.

The UI may show:

Payment detected. Waiting for completion.
Enter fullscreen mode Exit fullscreen mode

The hosting action should normally begin only after paid.

A manually accepted payment should follow a separate merchant policy and preserve an audit record.

Verify before an irreversible action

A valid callback proves that OxaPay sent the event.

Before provisioning a paid service, retrieve the latest Payment Information and verify:

  • payment status
  • track_id
  • merchant ownership
  • order_id
  • expected amount
  • currency policy
  • absence of a conflicting payment session
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,
  order,
}) {
  if (
    String(payment.status ?? "").toLowerCase() !== "paid"
  ) {
    throw new PermanentFulfillmentError(
      "Payment is not in the paid state",
    );
  }

  if (
    String(payment.order_id) !==
    String(order.externalOrderId)
  ) {
    throw new PermanentFulfillmentError(
      "Payment order_id does not match hosting order",
    );
  }

  if (
    decimal(payment.amount).lessThan(
      decimal(order.expectedAmount),
    )
  ) {
    throw new PermanentFulfillmentError(
      "Paid amount does not satisfy hosting order",
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Use decimal arithmetic for financial comparisons.

Do not compare financial values with ordinary JavaScript floating-point operations.

Use hosting fulfillment adapters

A new service and a renewal should not execute the same code path.

const hostingActions = {
  new_service: provisionNewService,
  renewal: renewExistingService,
  upgrade: upgradeExistingService,
};

export async function fulfillHostingOrder({
  order,
  paymentSession,
}) {
  const action = hostingActions[order.orderType];

  if (!action) {
    throw new PermanentFulfillmentError(
      `Unsupported hosting order type: ${order.orderType}`,
    );
  }

  return action({
    order,
    paymentSession,
  });
}
Enter fullscreen mode Exit fullscreen mode

The shared checkout core can handle:

  • payment creation
  • verification
  • event storage
  • job execution
  • retries
  • audit history

The adapter owns the hosting-specific action.

Provision a new hosting service idempotently

A duplicate callback or job retry must not create two servers.

Create a stable idempotency key:

merchant_id
+ payment_session_id
+ provision_new_service
Enter fullscreen mode Exit fullscreen mode
async function provisionNewService({
  order,
  paymentSession,
}) {
  const idempotencyKey = [
    order.merchantId,
    paymentSession.id,
    "provision_new_service",
  ].join(":");

  const job = await db.fulfillmentJob.upsert({
    where: {
      idempotencyKey,
    },
    create: {
      merchantId: order.merchantId,
      hostingOrderId: order.id,
      paymentSessionId: paymentSession.id,
      actionType: "provision_new_service",
      idempotencyKey,
      status: "running",
      attempts: 1,
    },
    update: {},
  });

  if (job.status === "completed") {
    return job;
  }

  try {
    const result = await hostingPlatform.provision({
      customerId: order.customerId,
      planId: order.planId,
      externalOrderId: order.externalOrderId,
      idempotencyKey,
    });

    const service = await verifyProvisionedService({
      externalServiceId: result.serviceId,
      expectedPlanId: order.planId,
    });

    await db.$transaction(async (tx) => {
      await tx.hostingOrder.update({
        where: { id: order.id },
        data: {
          orderStatus: "completed",
          serviceId: service.id,
        },
      });

      await tx.fulfillmentJob.update({
        where: { id: job.id },
        data: {
          status: "completed",
          completedAt: new Date(),
        },
      });
    });

    return service;
  } catch (error) {
    await recordFulfillmentFailure({
      fulfillmentJobId: job.id,
      error,
    });

    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

An accepted API request is not always the final outcome.

Verify that the service exists and has the expected plan before marking fulfillment as complete.

Renew a service without extending it twice

A naive renewal implementation does this:

service.expiry += 1 month
Enter fullscreen mode Exit fullscreen mode

If the event is processed twice, the customer receives two months.

Instead, create one immutable service-period grant per payment session.

async function renewExistingService({
  order,
  paymentSession,
}) {
  return db.$transaction(async (tx) => {
    const existingGrant =
      await tx.servicePeriodGrant.findUnique({
        where: {
          paymentSessionId: paymentSession.id,
        },
      });

    if (existingGrant) {
      return existingGrant;
    }

    const service = await tx.hostingService.findUnique({
      where: {
        id: order.serviceId,
      },
    });

    if (!service) {
      throw new PermanentFulfillmentError(
        "Hosting service not found",
      );
    }

    const now = new Date();

    const startsAt =
      service.expiresAt && service.expiresAt > now
        ? service.expiresAt
        : now;

    const endsAt = addMonthsUtc(
      startsAt,
      order.billingPeriodMonths,
    );

    const grant = await tx.servicePeriodGrant.create({
      data: {
        merchantId: order.merchantId,
        serviceId: service.id,
        hostingOrderId: order.id,
        paymentSessionId: paymentSession.id,
        startsAt,
        endsAt,
        monthsGranted: order.billingPeriodMonths,
      },
    });

    await tx.hostingService.update({
      where: {
        id: service.id,
      },
      data: {
        expiresAt: endsAt,
        status: "active",
      },
    });

    await tx.hostingOrder.update({
      where: {
        id: order.id,
      },
      data: {
        orderStatus: "completed",
      },
    });

    return grant;
  });
}
Enter fullscreen mode Exit fullscreen mode

The unique constraint on payment_session_id prevents duplicate renewal.

The period-grant record explains exactly why the service expiry changed.

Distinguish retryable and permanent failures

Some fulfillment failures may succeed later.

Retryable

  • hosting API timeout
  • temporary database outage
  • rate limiting
  • billing panel unavailable
  • network interruption
  • provisioning queue overloaded

Permanent

  • hosting plan no longer exists
  • order has no customer ID
  • service ID is invalid
  • payment does not match the order
  • merchant credentials are invalid
  • requested upgrade is not supported

Retry temporary failures with backoff.

Send permanent failures directly to manual review.

class TransientFulfillmentError extends Error {}
class PermanentFulfillmentError extends Error {}

function classifyHostingError(error) {
  if (
    error.status === 429 ||
    error.status === 502 ||
    error.status === 503 ||
    error.code === "ETIMEDOUT"
  ) {
    return new TransientFulfillmentError(error.message);
  }

  return new PermanentFulfillmentError(error.message);
}
Enter fullscreen mode Exit fullscreen mode

After retry exhaustion:

Payment remains paid
Fulfillment becomes needs_attention
Operational case is created
Merchant is notified
Customer receives a safe status message
Enter fullscreen mode Exit fullscreen mode

Handle expired payment sessions explicitly

An expired checkout is not the same as a cancelled hosting order.

The customer may:

  • abandon payment
  • create a replacement invoice
  • send funds shortly before expiry
  • send funds after expiry
  • attempt to reuse old payment details

Your application should:

  1. Mark the payment session expired.
  2. Keep the hosting order pending for a defined period.
  3. Allow creation of a replacement payment session.
  4. Preserve the old track_id.
  5. Prevent two payment sessions from fulfilling the same order twice.
  6. Send late-payment claims to review.
  7. Never tell the customer to pay again until the existing claim is checked.

An order can have several payment attempts:

Hosting order ORD-812
  Payment session A: expired
  Payment session B: paid
Enter fullscreen mode Exit fullscreen mode

The order is fulfilled once from session B.

Session A remains part of the audit history.

Build support prevention into the checkout

Crypto checkout UX is part of payment reliability.

For a hosting customer, show:

  • merchant name
  • hosting plan
  • service or invoice identifier
  • billing period
  • exact amount
  • selected currency
  • selected network
  • payment address
  • QR code
  • expiry timer
  • current payment state
  • what happens after confirmation
  • support link

Useful instructions include:

Send only the selected currency on the displayed network.

Send the exact amount shown on this page.

Do not send another payment while the transaction is confirming.

Your service will be provisioned or renewed after the payment reaches the paid state.

Keep your order ID for support.
Enter fullscreen mode Exit fullscreen mode

When payment activity is detected:

Payment detected. It is still being processed. Do not pay again.
Enter fullscreen mode Exit fullscreen mode

After confirmation:

Payment confirmed. Your hosting service is being prepared.
Enter fullscreen mode Exit fullscreen mode

If provisioning fails:

Your payment is confirmed, but service activation needs attention. You do not need to send another payment.
Enter fullscreen mode Exit fullscreen mode

That last message prevents duplicate payments and unnecessary customer anxiety.

Give the customer a status page

The return page should not be a generic “thank you” screen.

Use a persistent status page:

/hosting/orders/ORD-812/payment-status
Enter fullscreen mode Exit fullscreen mode

It can show:

Order created
Payment session created
Waiting for payment
Payment detected
Payment confirmed
Provisioning service
Service active
Enter fullscreen mode Exit fullscreen mode

The browser should query your backend, not OxaPay directly.

Do not expose:

  • Merchant API Keys
  • raw webhook payloads
  • internal errors
  • server credentials before authorization
  • private support notes
  • other customer records

The customer-facing page presents business state.

The admin interface retains technical evidence.

Build an operations dashboard

The merchant dashboard should answer:

Which payments are waiting?

Which paid orders are not provisioned?

Which renewals failed?

Which payment sessions expired?

Which cases need support?

What happened to this specific order?
Enter fullscreen mode Exit fullscreen mode

Useful views include:

Needs attention

  • paid but provisioning failed
  • paid but service record missing
  • renewal paid but expiry unchanged
  • payment without a matching order
  • underpaid payment
  • expired payment with a customer claim
  • repeated invalid HMAC attempts

Order timeline

09:30:11  Hosting order created
09:30:12  OxaPay invoice created
09:34:08  Payment detected
09:35:17  Payment confirmed
09:35:18  Provisioning job queued
09:35:31  Hosting API timed out
09:36:01  Retry scheduled
09:38:12  Service provisioned
09:38:14  Credentials delivered
Enter fullscreen mode Exit fullscreen mode

Safe actions

  • refresh Payment Information
  • copy customer status link
  • retry fulfillment
  • assign case
  • create replacement invoice
  • add support note
  • escalate to developer
  • export payment record

Manual actions must be permission-controlled and audited.

Recover missed events

Webhooks provide the real-time path.

They should not be the only path.

Use OxaPay Payment Information to refresh one payment by track_id.

Use Payment History to run scheduled recovery.

A practical schedule:

Every 10 minutes:
- query an overlapping recent payment window
- upsert records by track_id
- identify paid payments missing locally
- identify payments whose fulfillment never completed
- create recovery events

Every night:
- compare paid payments with completed hosting orders
- compare renewals with service-period grants
- report unresolved operational cases
Enter fullscreen mode Exit fullscreen mode

A recovered event should be labeled clearly:

source = payment_history_backfill
Enter fullscreen mode Exit fullscreen mode

Do not make it appear as if the original webhook arrived.

Evidence provenance matters when investigating failures.

The MVP

Do not build a generic multi-industry checkout platform.

Build one reliable hosting workflow.

A strong first version includes:

  • one hosting merchant
  • one billing or hosting platform adapter
  • hosted OxaPay invoice
  • order and track_id mapping
  • HMAC-validated webhook ingestion
  • event and outbox storage
  • one new-service provisioning flow
  • one renewal flow
  • idempotent fulfillment
  • customer payment status page
  • needs-attention dashboard
  • Payment Information refresh
  • Payment History recovery
  • daily unresolved-case report

The MVP must prove:

A confirmed hosting payment causes exactly one correct service action, and every failure remains visible.
Enter fullscreen mode Exit fullscreen mode

That is enough to solve a real merchant problem.

What comes after the MVP?

Add features only when the niche requires them:

  • white-label checkout
  • custom merchant branding
  • multiple billing panels
  • plan upgrades
  • account balance top-ups
  • multiple service types
  • reseller support
  • role-based merchant access
  • configurable renewal policies
  • customer notifications
  • accounting exports
  • agency white-label mode
  • usage-based merchant billing
  • operational SLA monitoring

Do not start with all of them.

Reliability creates more value than feature count.

Productize the hosting outcome

Weak positioning:

I integrate OxaPay with websites.

Better positioning:

I build crypto checkout integrations for hosting providers.

Stronger positioning:

I connect confirmed crypto payments to hosting invoices, service provisioning, renewals, customer status pages, and support recovery.

The product can be packaged as:

Standard hosting integration

  • payment creation
  • billing-panel connection
  • verified callbacks
  • normal invoice updates

Managed hosting checkout

  • provisioning and renewals
  • retry handling
  • payment status pages
  • operational alerts
  • recovery jobs

White-label hosting payment system

  • branded checkout
  • custom network instructions
  • multi-panel support
  • support timeline
  • agency or reseller capabilities

The merchant is not buying an API connection.

They are buying a reliable connection between payment and service delivery.

Common mistakes

Building for several industries at once

Hosting, courses, gaming, SaaS, and communities have different fulfillment rules.

Choose one public positioning.

Rebuilding an existing plugin without added value

Use the plugin when it solves the merchant's actual problem.

Build custom software only when the workflow requires more.

Provisioning inside the webhook request

Persist first. Provision asynchronously.

Treating paying as final

Do not activate paid service before the approved payment state.

Updating service expiry without an immutable grant

A duplicate event can otherwise extend the service twice.

Deduplicating callbacks but not business actions

Different callback payloads may still represent the same payment transition.

Use fulfillment-level idempotency.

Treating an expired session as the end of the order

Allow controlled replacement payment sessions and preserve history.

Hiding fulfillment failures

A paid-but-unprovisioned order must be highly visible.

Exposing raw payment data to customers

Translate infrastructure events into safe business statuses.

Ignoring recovery

A merchant-grade system must detect missed callbacks and incomplete fulfillment.

Final takeaway

A vertical crypto checkout is not a generic payment page with industry-specific colors.

It is a payment system that understands one merchant workflow deeply.

For hosting providers, that workflow is:

Hosting order
-> Payment session
-> Verified payment
-> Provisioning or renewal
-> Outcome verification
-> Customer and support visibility
Enter fullscreen mode Exit fullscreen mode

OxaPay provides the underlying payment primitives:

  • hosted invoices
  • white-label payment details
  • unique track_id references
  • HMAC-signed callbacks
  • Payment Information
  • Payment History
  • hosting-oriented plugins
  • SDKs

The developer builds the hosting-specific control layer around them.

Start with one hosting provider.

Connect one confirmed payment to one correct service action.

Make retries safe.

Make duplicate actions impossible.

Make failures visible.

That is how checkout becomes a vertical product instead of another payment integration.

Would you begin with new-service provisioning, automated renewals, or a paid-but-not-provisioned recovery dashboard?

Related articles

References

Top comments (0)