Writing
The Cash Register Is the Database: Building a Restaurant Ordering Platform on Spree + Square
August 13, 2026 · Amit Solanki
Most “build an online ordering site for a restaurant” projects start the same way: create a product catalog in your e-commerce platform, give staff a second admin UI to keep it in sync with whatever’s on the actual menu, and hope nobody forgets to 86 the salmon in both places at once.
We didn’t want that second source of truth. The restaurant already runs Square at the counter — staff update prices, add specials, and mark things sold out there, all day, without thinking about it. So we flipped the usual setup: Square owns the menu. Spree just mirrors it.
This post — Part 1 of a four-part series — is about spree-restaurant, the Spree Commerce platform that decision produced — the architecture that fell out of it, and the couple of non-obvious design calls that made it work cleanly. Part 2 digs into one piece of it specifically: the Square integration, which turned out clean enough to spin off as its own open-source gem. Part 3 covers the DoorDash delivery piece that came after, and Part 4 covers the RAG menu chat assistant that came after that.
The shape of the system
Three pieces, each independently deployable:
spree_square is the only piece that talks to Square. It runs a full catalog import plus real-time webhook handling, so a price change or 86’d item in Square shows up on the storefront within seconds — no separate menu management, no drift. Here’s the actual storefront, rendering a Square-sourced category straight through the Store API:

One licensing note that shaped this more than expected: Spree’s official Rails storefront is AGPL-3.0, which means self-hosting it and serving it over the web triggers AGPL §13’s source-disclosure clause. Spree’s official Next.js storefront is genuinely MIT-licensed, so that’s what customers actually hit — the backend only speaks Store API and never renders a page directly. Worth checking before you pick a storefront if you’re not planning to open your own source.
The payment trick: never build a Square gateway
The obvious way to get a paid order into Square is a Square payment gateway — customer enters a card, Square processes it, done. We didn’t build one, and the system still shows every order in Square as a fully paid kitchen ticket.
Square’s Orders API has a first-class way to record money that was collected somewhere else — its own docs use exactly this scenario, a food delivery service collecting payment on Square’s behalf, as the canonical example. So the flow is: Spree’s built-in payment method takes the customer’s payment at checkout, same as any Spree store. On order completion, the order gets created in Square, and a payment is recorded against it for the exact total Square itself computed — not a total recalculated independently, which means the payment can never mismatch the order it’s attached to. The kitchen sees a paid ticket on the POS/KDS, indistinguishable from a walk-in sale, seconds after checkout.
From the customer’s side, it’s an ordinary Spree checkout — here’s the actual form, address filled in, shipping method resolved:

…and the confirmation page after placing the order — this exact order (#R493631002) landed in Square as a paid ticket within the same request:

Zero payment-gateway code. The whole catalog-sync → order-push → status-sync loop is demoable without ever touching Square’s card-processing APIs — and the decision of which real payment gateway to eventually wire up (Stripe via an off-the-shelf gem, versus a custom Square Web Payments SDK integration for a single unified money flow) is now a decision that can be made deliberately later, not one that blocks everything else.
Modifiers are not variants
Square modifier lists — “choose your sauce,” multi-select, each option with its own price delta — are a per-line-item customization. Spree’s variant system, by contrast, models mutually exclusive SKUs. Force “choose your sauce” through that and a burger with 6 possible sauce combinations times 4 possible sides times 3 spice levels becomes a variant matrix nobody wants to manage.

Modifiers get their own small, separate model instead, joined to the line item — and the important detail is that a selected modifier is snapshotted at add-to-cart time: its name and price are copied onto the order, not referenced live from the Square object. A menu edit in Square next week can’t retroactively change what a customer paid for, or what a past order’s receipt shows. The price rolls straight into the line item’s own price field, so Spree’s existing pricing and promotion math keeps working unmodified — nothing extra to maintain in parallel. And because the snapshot already has everything Square needs, pushing the order back to Square at checkout is a direct match onto Square’s own per-line-item modifiers — no lookup required, and the kitchen ticket always matches what was actually charged. Adding it to cart is just an ordinary Spree add-to-cart under the hood:

Order status has more states than Spree does
Square’s fulfillment lifecycle — proposed, reserved, prepared, completed, plus canceled/failed — is more granular than anything Spree models natively. The mapping we landed on:
| Square state | Spree action |
|---|---|
| Fulfillment proposed | no-op — order’s already complete, kitchen hasn’t accepted yet |
| Fulfillment reserved / prepared | recorded as a friendly status label on the order, not forced into shipment state — Spree has no “food is cooking” concept, and pretending otherwise would corrupt the shipment state machine |
| Fulfillment completed | ships the order, cascading into Spree’s own shipment state |
| Order canceled | canceled on the Spree side too — but no automatic refund. The sync only updates status; the refund decision stays with staff |
| Order completed | ensure shipped, idempotently |
That table looks simple written down, but two real Square constraints surfaced only by testing against a live sandbox order, not by reading docs.
The first: Square flatly refuses to cancel a paid order’s top-level state — it rejects the request outright once a payment has been processed against it. We hit that error directly while testing a cancellation end-to-end. A paid order that isn’t happening gets its fulfillment canceled instead (the order itself stays completed for Square’s own accounting), and that’s the actual signal the sync watches for.
The second, subtler one: Square can deliver two different webhooks — one for the order, one for its fulfillment — reporting the identical version number for what is, from Square’s side, a single underlying mutation. A naive “only apply events newer than the last version we saw” staleness gate — which sounds exactly like the right thing to build against out-of-order webhook delivery — would let whichever event arrived first “claim” that version number and silently drop the other event’s information. If the dropped one happened to be the cancellation, an order would sit shown as complete when Square had already canceled it. The fix was to not gate cancellation on version at all; safety against duplicate delivery comes instead from tracking Square’s own event IDs, and from every status transition being safe to apply twice. It’s the kind of bug that only shows up once you’re testing against a real webhook stream instead of hand-written fixtures — which is exactly why it got a regression test locking in both delivery orders once found.
What the integration actually does today
Beyond the three decisions above, here’s the full capability surface as it stands:
- One-way catalog sync — items, variations, categories, images, and modifier lists, via a full importer plus real-time webhooks for catalog changes and inventory/stock updates.
- Order push with EXTERNAL payment — completed Spree orders pushed into Square as paid tickets, targeting whichever Square location the order’s fulfilling from.
- Bidirectional order status — fulfillment and cancellation sync back from Square to Spree, as described above.
- Self-service Square connection — a “Connect to Square” flow in the admin (OAuth, not a hand-issued token), with encrypted per-store credential storage and automatic token refresh. A plain access token still works too, for quick local development.
- A nightly reconciliation pass — re-runs the catalog import and inventory sync as a full pass, as a backstop for any webhook that got missed.
- Dead-letter alerting — a failed order push is the worst failure mode in the whole system (payment already taken, kitchen never sees the ticket), so it retries with backoff and then raises a real alert rather than failing silently.
- Admin visibility — pages listing order mappings and webhook event history, for support staff to see what synced and what didn’t without needing a console.
All of this runs against real Square sandbox infrastructure, not mocked: a full demo menu (35 items across 7 categories, real photos) seeded into Square and synced into Spree, a storefront checkout that pushes a paid ticket into Square within the same request (the order shown in the screenshots above is a real one — cancelled and refunded afterward, this being a demo), and status changes in Square — advancing fulfillment, canceling an order — reflected back on the customer’s order page without anyone refreshing a sync job by hand.
Where this is headed
What’s next for spree-restaurant is DoorDash Drive, for delivery dispatch — keyed off the same per-location mapping this sync already needed to build.
Before that: the Square integration piece of this project turned out to be clean enough, as a piece of engineering, to publish as its own open-source gem — spree_square — rather than staying locked inside this one restaurant’s codebase. That process, and a real production bug that shipping it as a versioned package caught almost immediately, is the subject of the next post.
Working through something similar?