Shopify makes it easy to create an order.
That is not the hard part.
The hard part starts when the payment does not behave like a clean, instant, single-step event.
A webhook arrives twice.
A payment is delayed.
The amount is slightly different.
The order was already cancelled.
The customer says they paid, but your system still shows pending.
If your integration assumes:
payment_received = order_paid
it will probably work in testing.
It will fail in production.
The real challenge is not “how to accept a payment on Shopify.”
It is how to design a payment flow that stays consistent when payment events are asynchronous, duplicated, delayed, or incomplete.
The Core Rule: Order State and Payment State Must Be Separate
A Shopify order and an external payment are related, but they are not the same object.
The order belongs to Shopify.
The payment belongs to your system.
If you merge them too early, you lose control.
A better architecture looks like this:

The payment system should decide payment state first.
Only after that should Shopify be updated.
A Practical Payment State Machine
For an external payment flow, you need states.
Not just paid and unpaid.
A more realistic model looks like this:
A payment lifecycle might include:
const PaymentState = Object.freeze({
PENDING: "pending",
AWAITING_CONFIRMATION: "awaiting_confirmation",
CONFIRMED: "confirmed",
UNDERPAID: "underpaid",
OVERPAID: "overpaid",
EXPIRED: "expired",
FAILED: "failed",
});
This matters because a webhook is not your source of truth.
A webhook is only a signal that something changed.
Your database should store the current payment state.
Data Model: Store Payments Separately
Here is a simple payment record model:
// payments table / collection
{
id: "pay_123",
shopifyOrderId: "gid://shopify/Order/123456789",
providerInvoiceId: "inv_987",
expectedAmount: 49.99,
receivedAmount: 0,
currency: "USD",
status: "pending",
expiresAt: "2026-05-05T12:00:00Z",
createdAt: "2026-05-05T11:30:00Z",
updatedAt: "2026-05-05T11:30:00Z"
}
You also need a webhook event log:
// webhook_events table / collection
{
eventId: "evt_abc123",
providerInvoiceId: "inv_987",
eventType: "payment.confirmed",
processedAt: "2026-05-05T11:45:00Z"
}
Without this second table, duplicate webhooks will eventually hurt you.
Example: Express Webhook Handler
Below is a simplified but realistic Node.js example.
It shows:
idempotency
signature verification placeholder
payment lookup
state transition
Shopify order sync
import express from "express";
import crypto from "crypto";
const app = express();
// Important: For production, you should use raw body for signature verification
app.use(express.json());
const PaymentState = Object.freeze({
PENDING: "pending",
AWAITING_CONFIRMATION: "awaiting_confirmation",
CONFIRMED: "confirmed",
UNDERPAID: "underpaid",
OVERPAID: "overpaid",
EXPIRED: "expired",
FAILED: "failed",
});
const db = {
payments: new Map(), // key = providerInvoiceId
webhookEvents: new Set(), // for idempotency
getPayment(invoiceId) {
return this.payments.get(invoiceId);
},
savePayment(payment) {
this.payments.set(payment.providerInvoiceId, payment);
},
hasProcessedEvent(eventId) {
return this.webhookEvents.has(eventId);
},
storeEvent(eventId) {
this.webhookEvents.add(eventId);
},
};
function verifySignature(req) {
// TODO: Implement real signature verification in production
// Example:
// const signature = req.headers["x-signature"];
// const payload = JSON.stringify(req.body); // Use raw body in real implementation
// const expected = crypto
// .createHmac("sha256", process.env.WEBHOOK_SECRET)
// .update(payload)
// .digest("hex");
// return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
return true; // For development only
}
function calculateNextPaymentState(payment, event) {
const expected = Number(payment.expectedAmount);
const received = Number(event.receivedAmount || 0);
if (event.status === "failed") {
return PaymentState.FAILED;
}
if (event.status === "expired") {
return PaymentState.EXPIRED;
}
if (event.status === "pending") {
return PaymentState.AWAITING_CONFIRMATION;
}
if (event.status === "confirmed") {
if (received < expected) return PaymentState.UNDERPAID;
if (received > expected) return PaymentState.OVERPAID;
return PaymentState.CONFIRMED;
}
return payment.status;
}
async function markShopifyOrderAsPaid(orderId) {
// Implement Shopify Admin API call here
console.log(`[Shopify] Marking order as paid: ${orderId}`);
}
async function addShopifyOrderNote(orderId, note) {
// Implement Shopify Admin API call here
console.log(`[Shopify] Adding note to order ${orderId}: ${note}`);
}
async function syncShopifyOrder(payment) {
if (payment.status === PaymentState.CONFIRMED) {
await markShopifyOrderAsPaid(payment.shopifyOrderId);
return;
}
if (payment.status === PaymentState.UNDERPAID) {
await addShopifyOrderNote(
payment.shopifyOrderId,
`Payment underpaid. Expected ${payment.expectedAmount}, received ${payment.receivedAmount}.`
);
return;
}
if (payment.status === PaymentState.EXPIRED) {
await addShopifyOrderNote(
payment.shopifyOrderId,
"Payment request expired before confirmation."
);
}
}
// ====================== WEBHOOK ENDPOINT ======================
app.post("/webhooks/payment", async (req, res) => {
try {
if (!verifySignature(req)) {
return res.status(401).json({ error: "Invalid signature" });
}
const event = req.body;
if (!event.eventId || !event.invoiceId) {
return res.status(400).json({ error: "Invalid webhook payload" });
}
// Idempotency check
if (db.hasProcessedEvent(event.eventId)) {
return res.status(200).json({ status: "duplicate_ignored" });
}
const payment = db.getPayment(event.invoiceId);
if (!payment) {
return res.status(404).json({ error: "Payment not found" });
}
// Calculate new state
const nextState = calculateNextPaymentState(payment, event);
// Update payment
payment.status = nextState;
payment.receivedAmount = event.receivedAmount || payment.receivedAmount;
payment.updatedAt = new Date().toISOString();
// Save changes
db.savePayment(payment);
db.storeEvent(event.eventId);
// Sync with Shopify
await syncShopifyOrder(payment);
return res.status(200).json({
status: "processed",
paymentStatus: nextState
});
} catch (error) {
console.error("Webhook handling failed:", error);
return res.status(500).json({ error: "Internal webhook error" });
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Webhook server running on port ${PORT}`);
});
Shopify Sync Should Be a Separate Function
Avoid placing Shopify API logic directly inside the webhook handler.
Keep it separate.
async function markShopifyOrderAsPaid(orderId) {
// Use Shopify Admin API here.
// In production, this may involve transactions, order notes,
// tags, or fulfillment logic depending on your setup.
console.log(`Marking Shopify order as paid: ${orderId}`);
}
async function addShopifyOrderNote(orderId, note) {
// Use Shopify Admin API to add an order note or tag.
// Useful for underpaid, expired, or manual-review cases.
console.log(`Adding note to ${orderId}: ${note}`);
}
Why?
Because payment state and order update are two different responsibilities.
Your webhook handler should process payment truth.
Your Shopify sync layer should translate that truth into order actions.
Why Idempotency Matters
Payment providers may retry webhooks.
Network issues happen.
Your endpoint may respond late.
So the same event can arrive more than once.
Bad logic:
await markShopifyOrderAsPaid(orderId);
await sendCustomerEmail(orderId);
await createFulfillment(orderId);
If this runs twice, you may send duplicate emails or trigger duplicate fulfillment.
Better logic:
if (await db.hasProcessedEvent(event.eventId)) {
return;
}
await processEvent(event);
await db.storeEvent(event.eventId);
In production, this should be enforced at the database level with a unique constraint on eventId.
Where External Payment Flows Usually Break
Most bugs come from assumptions like these:
“The webhook will arrive once.”
It may not.
“The payment amount will match exactly.”
Network fees, wrong coin selection, or user mistakes can create mismatches.
“The order still exists.”
The customer may cancel, or your system may expire the order.
“Confirmed means fulfilled.”
Not always. Some products may require fraud checks, manual review, or additional business logic.
This is why your integration should never be:
webhook received → order paid
It should be:
webhook received → validate → deduplicate → update payment state → apply business rules → sync Shopify
Where OxaPay Fits in This Architecture
At this point, the natural question is not “which provider should I use?”
It is:
Do I want to build the payment lifecycle myself, or use a payment system that already gives me structured invoices, trackable payment states, and callbacks?
For crypto payment flows, OxaPay fits as the external payment layer in this architecture.
The useful part is not just that it accepts crypto.
The useful part is that it gives you a structured payment object instead of forcing you to track raw wallet transactions manually.
A Shopify flow can look like this:
This is not about replacing Shopify’s checkout logic.
It is about adding a structured external flow for cases where a merchant wants to support crypto payments alongside their existing payment setup.
Useful references:
OxaPay Shopify automation guide:
https://docs.oxapay.com/welcome-to-oxapay/integrations/make-automation/shopify
Make integration page:
https://www.make.com/en/integrations/oxapay-crypto-pay-gtw/shopify
Final Checklist for Developers
Before shipping an external payment flow, check this:
Do you store payments separately from Shopify orders?
Do you have a unique payment reference?
Do you verify webhook signatures?
Do you deduplicate webhook events?
Do you handle underpaid, overpaid, expired, and failed payments?
Do you separate payment state updates from Shopify order sync?
Do you have logs for reconciliation?
Do you avoid assuming that one webhook means one final payment?
If the answer is no to any of these, your payment flow may work in testing but fail in production.
Final Thought
Payment integrations fail when they treat money movement like a simple API response.
It is not.
A reliable Shopify external payment flow needs state, idempotency, verification, and clear separation between payment logic and order logic.
The goal is not to make the first payment work.
The goal is to make the thousandth payment work, even when the webhook arrives twice, the payment is late, and the customer is already asking support what happened.


Top comments (0)