Skip to content
Integrations · 7 min read

Two-Way POS Sync Is Harder Than the Docs

Webhooks that never fire, statuses that do not map, menus that drift: what two-way POS integration with Clover, Square and Toast actually involves.

The API documentation for a point-of-sale platform describes roughly a third of the work. The endpoints are fine, the authentication is ordinary, the payloads are documented. What the docs cannot tell you is that you are not building an integration, you are building a distributed system with a second writer you do not control, running on hardware in a kitchen, on a network shared with a card terminal and someone’s phone.

We run two-way sync with Clover, Square and Toast across both platforms we operate — the marketplace side of SuperApp and the restaurant side of Supaorder — so the failure modes below are the ones that generate support tickets, not the ones that generate blog posts.

Decide field-level ownership before writing any sync code

Two-way does not mean both systems own everything. It means each field has exactly one owner and the other side is a replica of it. Skipping this step produces the classic bug where a price flips back and forth every few minutes because two syncs disagree and each is “fixing” the other.

FieldUsually owned byWhy
Item name, price, tax groupPOSIt is what the till charges in person; a mismatch is an accounting problem
Modifiers and modifier groupsPOSThe kitchen expects the POS’s structure on the ticket
Online availability, images, descriptionsPlatformFrequently not modelled in the POS at all
Online order and its contentsPlatformCreated online; the POS receives it
Order lifecycle after firePOS, then platformThe POS knows when the kitchen accepted it; the platform knows about the courier
Customer identity and marketing consentPlatformThe POS’s customer record is usually a receipt, not a profile

Write this table for your own integration, agree it with the operator, then make the code enforce it: a sync that receives a change to a field it does not own logs it and drops it rather than writing it back.

Webhooks are a latency optimisation, never a source of truth

Assume every webhook you rely on will at some point not arrive. The reasons are mundane and all happen in production:

  • The subscription was disabled, expired or silently unregistered when the merchant reinstalled the app.
  • Your endpoint returned 500s for four minutes during a deploy and the provider’s retry budget ran out before your rollback finished.
  • The event type you needed is not the event type that fired — an item edited on the device produced a catalogue-updated event with no detail about what changed.
  • The store’s internet dropped mid-service, which happens more often than any other item on this list.
  • Two events arrived out of order, so “order closed” landed before the update that set the tip.

The structural answer is that a webhook only ever accelerates something a scheduled reconciliation would have caught anyway. Every handler should be a thin receipt: verify the signature, persist the raw event with its provider id, enqueue, return 200 fast. Do the work in a consumer that can be replayed. And keep the polling path alive even when webhooks are healthy — it is not a fallback you build during an incident, it is the mechanism that makes the incident invisible.

Then instrument the absence. Alert on “no events from this merchant in N minutes during their trading hours”, because the failure you need to detect is silence, and silence does not raise exceptions. That kind of business-level alerting is most of what operating the platforms ourselves taught us.

Idempotency, or: how a customer gets charged twice

Every write to the POS, and every write your handlers make, must be safe to repeat — because it will be repeated.

Three rules cover most of it. Dedupe inbound events on the provider’s event id, stored with a unique constraint — not checked-then-inserted, which is a race with a small window and a memorable outcome. Give every outbound write a stable idempotency key derived from your own domain rather than a timestamp or a value generated at call time; if the call is retried after a network timeout the key must be identical, which means persisting it before the request goes out. And treat “I do not know whether that succeeded” as the normal case: a timeout is not a failure, so the recovery path is to look the resource up by your key before creating it again.

The specific disaster this prevents is the duplicate ticket. Your service posts an order, the connection times out after the POS has already accepted it, the retry posts it again, and the kitchen produces two identical meals for one paying customer. The kitchen finds this out before you do.

Statuses do not map, and pretending they do loses orders

Your platform has a lifecycle shaped by a customer waiting: placed, accepted, preparing, ready, dispatched, delivered. The POS has one shaped by a till and a kitchen: open, fired, held, partially paid, closed, voided. These are not the same set with different names, and the mapping is not a lookup table — it is a state machine with a documented resolution rule for each conflict.

The cases that need an explicit decision before implementation:

  • Void versus refund. The POS may void an order your platform has already captured payment for. A void is not a refund; someone still has the customer’s money.
  • Tips and adjustments after close. Totals change after the order is finished, so anything reconciled on totals must read the final state, not the state at close.
  • Partial refunds and item removals. A removed item changes the total, the tax and often the modifier structure. Supaorder’s live order editing with automatic refunds exists precisely because doing that by hand across a platform, a POS and a payment provider is where money goes missing.
  • Orders edited after dispatch. The receipt printed, the ticket fired, the courier left. Whatever your model says, the physical world has already committed.
  • The order the POS never saw. If the ticket did not print, the order does not exist to the kitchen, however green the platform’s UI is. Accepted-but-not-printed deserves its own alert.

A menu is not a document you can overwrite. Sync it as a proposed change set, show the operator what will be added, changed and removed, and require confirmation on destructive changes — the drift is constant and the failure is silent.

What drifts, specifically: items deactivated on the POS versus deleted (one should hide online, the other must not orphan order history); prices that include tax on one side and exclude it on the other; modifier groups with min/max rules the platform’s UI cannot express; the same item under two categories with two ids; ids reused after a deletion; and out-of-stock states that exist on only one side. Add multi-location groups, where a menu is really a template with per-location price overrides, and a naive full-overwrite sync will flatten a location’s overrides with an operator watching.

Two rules worth enforcing in code: never let a sync delete a catalogue item that has order history, only hide it; and keep your own stable internal id per item with the provider id as an attribute, so an id reused on the POS side does not silently merge two products.

Reconciliation is the feature, not the fallback

Every integration we run has a scheduled job that compares both sides and reports the difference: orders present on one side and not the other, totals that disagree, catalogue items whose price or availability diverged, and merchants whose token is near expiry. The output goes somewhere a support person can see it per merchant, next to a “resync this order” action — the fastest fix for a stuck order is a targeted replay, not a deploy.

That also changes what an estimate should look like. Two-way POS work is not one line item; it is one per vendor, and vendors differ in more than payload shape — device-side behaviour, catalogue versioning semantics, sandbox fidelity, and partner approval processes that gate production access on a timeline you do not control. Three POS vendors is three integrations behind a shared interface, not one integration with three adapters.

If you are scoping anything in this territory — POS, payments, dispatch, any system that also writes — it is custom integration work rather than a feature, and worth pricing as such. The platforms we build and run are where we found that out.

Keep Reading

Ready to Build Something That Ships?

Tell us what you're building. We'll get back to you within 24 hours with a clear plan and an honest estimate.

Free consultation · No upfront costs · Reply within 24 hours