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
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
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
For a renewal:
Payment confirmed
-> Extend the existing service period
-> Update the billing panel
-> Send renewal confirmation
For an upgrade:
Payment confirmed
-> Validate upgrade eligibility
-> Change service resources
-> Record the new plan
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
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.
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
A hosting order has several independent states.
Payment state
created
waiting
confirming
paid
underpaid
expired
refunded
Order state
pending_payment
paid
processing
completed
cancelled
manual_review
Fulfillment state
not_started
queued
running
completed
failed
needs_attention
Service state
pending
provisioning
active
suspended
terminated
This combination is valid:
Payment: paid
Order: processing
Fulfillment: failed
Service: pending
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
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_idmapping - 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
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
The Webhook Receiver should not provision a server or extend a service directly.
It should:
- Validate the callback.
- Store the payment event.
- Create an outbox job.
- 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
);
The order_type can identify:
new_service
renewal
upgrade
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}`;
}
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"
}
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}
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"),
);
}
The storage operation should use one database transaction:
Insert payment event
+
Insert outbox job
+
Commit
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",
};
}
Do not provision on paying.
The UI may show:
Payment detected. Waiting for completion.
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",
);
}
}
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,
});
}
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
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;
}
}
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
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;
});
}
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);
}
After retry exhaustion:
Payment remains paid
Fulfillment becomes needs_attention
Operational case is created
Merchant is notified
Customer receives a safe status message
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:
- Mark the payment session expired.
- Keep the hosting order pending for a defined period.
- Allow creation of a replacement payment session.
- Preserve the old
track_id. - Prevent two payment sessions from fulfilling the same order twice.
- Send late-payment claims to review.
- 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
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.
When payment activity is detected:
Payment detected. It is still being processed. Do not pay again.
After confirmation:
Payment confirmed. Your hosting service is being prepared.
If provisioning fails:
Your payment is confirmed, but service activation needs attention. You do not need to send another payment.
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
It can show:
Order created
Payment session created
Waiting for payment
Payment detected
Payment confirmed
Provisioning service
Service active
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?
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
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
A recovered event should be labeled clearly:
source = payment_history_backfill
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_idmapping - 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.
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
OxaPay provides the underlying payment primitives:
- hosted invoices
- white-label payment details
- unique
track_idreferences - 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
- 10 Crypto Payment Products Developers Can Build for Merchants
- Build a Crypto PaymentOps Service for Merchants
- Build a Payment Automation Studio for Crypto Merchants
- Build a Crypto Payment Reconciliation Tool for Merchants
- Build a Crypto Payment Support Desk
Top comments (0)