DEV Community

kevin.s
kevin.s

Posted on

Shopify Payments Are Simple, Until They Break

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
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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}`);
});

Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

It should be:

webhook received  validate  deduplicate  update payment state  apply business rules  sync Shopify
Enter fullscreen mode Exit fullscreen mode

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)