DEV Community

Cover image for How to Fix Shopify POS and EDC Terminal Reconciliation Mismatches
Lucy
Lucy

Posted on

How to Fix Shopify POS and EDC Terminal Reconciliation Mismatches

If your cashiers ring up the sale in Shopify POS and then key the same total into a separate EDC card terminal by hand, you already have two systems of record for the same transaction. This post walks through why that gap opens up, which integration model actually closes it, and what the API calls, webhook handling, and reconciliation logic look like when you build the bridge yourself.

Why Shopify POS and EDC Terminals Fall Out of Sync

Most retail counters running Shopify POS alongside a third-party EDC (Electronic Data Capture) terminal are running two independent systems that were never designed to talk to each other. The cashier totals the cart in Shopify POS, then re-enters that same amount on the terminal keypad, selects a payment mode, and waits for the customer to tap or insert a card.

At that point, two separate ledgers exist. Shopify has an order that is either unpaid or manually marked paid. The terminal has its own batch report with its own transaction ID, sitting in a completely different system. Nothing links the two unless a human writes the terminal's transaction ID onto a receipt or into an order note, and in practice, a lot of stores skip that step entirely because there's no fast way to do it at the counter under a queue of customers.

This is a known pattern in payments infrastructure, not a Shopify-specific quirk. Payment providers generally describe two ends of a spectrum: standalone terminals that process a card with zero coding effort but leave order and payment data unlinked, and integrated (or cloud) terminals that require an API integration but keep the two systems aligned automatically, as Airwallex's integrated terminal documentation lays out. Most EDC-based retail counters are running the standalone version today, and the reconciliation cost of that choice tends to stay invisible until someone adds up the hours.

Three Integration Models, and Why the Model You Pick Decides Your Reconciliation Story

Before writing a line of integration code, it's worth naming which model you're actually building toward, because each one has a different blast radius for PCI scope and engineering effort.

Model How payment data flows Reconciliation Typical fit
Standalone terminal Cashier re-keys the amount; terminal and POS never talk Fully manual, batch report vs. order list Single-location, low volume
Semi-integrated terminal POS sends the amount and receives a result; card data stays on the terminal, out of the POS app Automatic per-transaction match Multi-location retail, the common target for EDC bridges
Fully integrated terminal POS or its backend handles more of the payment flow directly with the processor Automatic, tightest coupling Vertical SaaS or processor-owned hardware

Semi-integration is the model most Shopify POS and EDC bridges land on, and for good reason. As Datatel Systems explains, a semi-integrated setup treats the terminal as the thing that actually touches card data, while the POS only ever sees an amount request and a result, which keeps sensitive data away from the POS application entirely. This is also the model that maps cleanly onto Shopify's own extension points, since the integration layer just needs to relay a request and listen for a confirmation, not process a card itself.

What Closing the Gap Looks Like at the API Level

Once the terminal confirms a payment, the job is to get that confirmation back into Shopify without a human re-typing anything. Shopify's Admin GraphQL API gives you two mutations built for exactly this kind of external payment record.

orderMarkAsPaid is built for orders where the outstanding balance needs to be cleared based on a payment that happened outside Shopify's standard checkout, which is precisely the situation once an EDC terminal confirms a card charge. According to Shopify's own mutation reference, the mutation either creates a new sale transaction for the full outstanding amount or captures an existing authorized transaction, and it updates the order's financial status once it succeeds.

mutation MarkOrderPaid($input: OrderMarkAsPaidInput!) {
  orderMarkAsPaid(input: $input) {
    order {
      id
      displayFinancialStatus
    }
    userErrors {
      field
      message
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
{
  "input": {
    "id": "gid://shopify/Order/820982911946154508"
  }
}
Enter fullscreen mode Exit fullscreen mode

If you need to record the terminal's own transaction ID, timestamp, or payment method label alongside the payment rather than just flipping a status, orderCreateManualPayment is the better fit. It lets you pass an amount, a method name, and a processedAt timestamp, which matters if your webhook arrives a few seconds after the actual charge and you want the order's payment record to reflect when the customer's card was actually charged, not when your server got around to processing the event.

A Reference Architecture for the Bridge


The actual bridge is a small, boring service that sits between the EDC provider's webhook or SDK callback and Shopify's Admin API. A minimal version looks like this in Node.js:

const express = require('express');
const app = express();
app.use(express.json());

// Prevent double-processing if the provider retries the same event
const processedEvents = new Set();

app.post('/webhooks/edc-terminal', async (req, res) => {
  const { transactionId, orderId, amount, status } = req.body;

  if (processedEvents.has(transactionId)) {
    return res.status(200).send('already processed');
  }

  if (status !== 'approved') {
    // Log declines and cancellations for the reconciliation log,
    // but don't touch the Shopify order.
    return res.status(200).send('logged, no action needed');
  }

  try {
    await markShopifyOrderPaid({ orderId, transactionId, amount });
    processedEvents.add(transactionId);
    res.status(200).send('reconciled');
  } catch (err) {
    // Don't swallow the error. A missed webhook here is exactly
    // the gap this whole integration exists to close.
    console.error('reconciliation failed', transactionId, err);
    res.status(500).send('retry me');
  }
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Three things in that snippet matter more than they look:

  1. Idempotency isn't optional. EDC providers commonly retry webhook delivery if they don't get a fast 200 response, and calling orderMarkAsPaid twice against the same order will surface a user error since the mutation checks that the order isn't already PAID, per the Order object's canMarkAsPaid field. A transaction-ID set (or a database table in production) avoids duplicate processing.
  2. Declines and cancellations still need to be logged, even though they shouldn't touch the Shopify order. This is what makes the eventual reconciliation log complete instead of only showing successful payments.
  3. Failures should return a 5xx, not swallow the error. If your bridge silently eats a failed API call, you've recreated the exact manual-matching problem this integration was built to remove, just one layer further from the counter where it's harder to spot. ## Where PCI Scope Actually Sits in This

A fair question from anyone reviewing this kind of integration is whether it touches cardholder data. It shouldn't, and if it does, the design needs to change before it ships.

In a semi-integrated model, the terminal captures and encrypts the card data itself, and the POS-side integration only ever sees an amount, a status, and a transaction reference. This is the architectural basis for PCI DSS scope reduction: as the PCI Security Standards Council notes in its own FAQ, devices that support Secure Reading and Exchange of Data can facilitate scope reduction for merchants when used as part of a validated point-to-point encryption solution, though a terminal being PTS-approved doesn't automatically guarantee that reduction on its own. The details vary by provider and by how the merchant's environment is segmented, which is why Adyen documents the reconciliation and payment-intent flow separately from card capture in its own point-of-sale API reference. The practical takeaway for anyone building this bridge: keep raw card data entirely off your server, treat the webhook payload as amount-and-status only, and confirm with the EDC provider exactly what their webhook or callback does and doesn't include before you write a single handler.

Common Mistakes When Teams Build This Themselves

A few patterns show up repeatedly in bridges like this, and most of them trace back to treating the integration as a one-off script instead of infrastructure.

  • No retry or dead-letter handling. A webhook that fails once and is never retried recreates the manual-matching problem for exactly the transactions that mattered enough to fail.
  • Assuming one terminal provider forever. Multi-location chains often run different EDC hardware in different regions. Building the bridge around a single provider's payload shape instead of a normalized internal event format makes adding a second provider a rewrite instead of a config change.
  • Skipping the reconciliation log. The API calls above solve the order-status half of the problem. The other half is a queryable log of every terminal event, successful or not, with order, store, provider, amount, status, and timestamp, so a finance team can filter by date range or location instead of stitching together exports by hand.
  • No plan for refunds. Getting a payment confirmation back into Shopify automatically is the first milestone, not the whole project. Refund flows initiated from the terminal side need the same kind of bridge in reverse, and teams that skip this end up back to manual reconciliation the moment a customer asks for money back. ## What "Good" Reconciliation Looks Like Once It's Live

Once the bridge is running, the operational difference is straightforward to describe: every terminal transaction, across every store and every provider, lands in one place with the same fields, instead of living in separate batch reports that someone has to manually cross-reference. A useful minimum schema for that log looks like this:

Field Why it matters
Order ID Links the terminal event back to the exact Shopify order
Store / location Needed once you're past a single counter
Provider Matters the moment you run more than one EDC vendor
Amount The number that actually needs to match, transaction by transaction
Status approved, declined, cancelled, or error, not just "paid"
Timestamp Traces disputes back to a specific charge, not just a day

We wrote up how this played out for a real multi-location retailer, including what the manual process was actually costing them before the fix, in our case study on closing the Shopify POS and EDC terminal reconciliation gap. If you're evaluating whether this is worth building in-house versus bringing in a team that's done it before, that write-up covers the actual before-and-after numbers. For teams earlier in the process, still deciding on the underlying Shopify build before tackling hardware integrations, a broader look at what a custom Shopify development engagement typically covers is a reasonable next stop.

Reconciliation gaps like this rarely get fixed because nobody logs a single incident bad enough to justify the project. It's a few minutes here, a mismatched batch there, a slower month-end close, spread thin enough that it never tops anyone's priority list on its own. But once you can name the exact API calls and webhook shape that close it, it stops being an ops tax and becomes a two-to-four week engineering project.

Have you built (or inherited) a POS-to-payment-terminal bridge like this? What broke first once it hit production, webhook retries, idempotency, or something else nobody planned for? Curious to hear how other teams handled it.

Top comments (0)