Writing
The Subscriber That Was Never Subscribed: Adding DoorDash Delivery to a Spree Commerce Platform
August 17, 2026 · Amit Solanki
This is Part 3 of a four-part series on spree-restaurant, a Spree Commerce platform built so Square owns the menu. Part 1 covers the platform architecture — spree-restaurant, Square as the menu’s source of truth, Spree mirroring it. Part 2 covers spree_square, the open-source extension that does the syncing. This post is about the third piece: delivery. Specifically, spree_doordash, the extension that quotes, dispatches, and tracks DoorDash Drive deliveries for orders placed on that platform — and two real bugs that only announced themselves once I stopped calling services directly and actually placed an order through the storefront. Part 4 covers the last piece: a RAG chat assistant grounded in the same synced menu.
Code’s here: github.com/amitkssolanki/spree_doordash · rubygems.org/gems/spree_doordash.
Delivery is just another shipping rate
The obvious way to bolt a delivery provider onto an e-commerce checkout is a bespoke integration — a new field on the order, a custom API endpoint the frontend calls to fetch a delivery quote, a special-cased branch in checkout for “if delivery, do this.” None of that turned out to be necessary.
Spree already has a shipping-rate system built for exactly this shape of problem: an order needs a price, computed at checkout time, for however it’s going to reach the customer. Spree::ShippingMethod already supports attaching any calculator that subclasses Spree::ShippingCalculator. So “DoorDash Delivery” is just a second shipping method next to “Pickup / Local Delivery,” and Spree::Calculator::Shipping::DoordashQuote#compute_package is a calculator that, instead of doing arithmetic on package weight, calls DoorDash’s live Quote API and returns whatever real fee comes back. Spree’s own stock estimator calls every available calculator for every shipping-rate request — checkout, admin, API — so the moment an order has a ship address, a live DoorDash quote just… shows up as an option, no custom endpoint required. The storefront needed zero new code — it already renders whatever shipping rates the Store API returns.
This is the actual checkout, mid-order, with a real address (1401 K St NW, Washington, DC — a real, DoorDash-serviceable location) and both shipping options resolved:

That $9.75 isn’t a stub or a fixture — it’s a real response from DoorDash’s Sandbox Quote API, computed for that exact pickup and dropoff pair, at the moment the checkout page loaded.
One assumption from the original plan turned out to be wrong, and only source-reading caught it before it became a bug: I’d assumed Spree::ShippingMethod auto-discovers any ShippingCalculator subclass, the way some Rails conventions auto-load anything matching a naming pattern. It doesn’t. spree_core populates a hardcoded array of available calculators via its own config.after_initialize, and a calculator defined outside that array is simply invisible to checkout — no error, the shipping method just never appears. The fix is one line, appending DoordashQuote to that array from a later-running initializer, but it’s exactly the kind of thing that looks like it should work, passes a unit test that instantiates the calculator directly, and then does nothing the first time it’s wired into a real checkout page.
Quote, then accept — not create directly
DoorDash’s own recommended flow for Drive is quote-then-accept, not “create a delivery directly.” A quote is valid for five minutes, and checkout can easily take longer than that — someone fills in an address, gets distracted, comes back. So every quote generated during checkout gets persisted (SpreeDoordash::QuoteMapping), and at the moment an order actually completes, DeliveryDispatcher accepts that stored quote if it’s still fresh, or silently re-quotes and accepts immediately if it expired. The customer never sees any of this — it’s the difference between “the price I saw is the price that gets dispatched” and “DoorDash makes something up on the fly,” handled entirely server-side.
This same order — Build-Your-Own Bowl with real modifier selections (Quinoa, Steak, Corn Salsa, Shredded Cheese), plus Loaded Nachos — is the one that carries through every screenshot in this post, start to finish:


Reusing the modifier system here wasn’t incidental — it’s the same LineItemModifier snapshot mechanism from spree_square, doing exactly what it already did: rolling the modifier price deltas into the line item, so DoorDash’s order_value (used for delivery insurance, not payment) reflects what the customer actually configured, not just the item’s base price.
The confirmation, and what happens next automatically
Placing the order looks like an ordinary Spree checkout confirmation — because from the customer’s side, it is one:

What isn’t visible on that page is everything that happens in the same request: order.completed fires, OrderCompletedSubscriber checks whether the order’s shipping method is DoorDash Delivery, and if so enqueues DeliveryDispatchJob, which accepts the stored quote and creates a real DoorDash delivery — tracking URL, external delivery ID, the works — with zero manual intervention. The admin’s DoorDash Deliveries page shows exactly that, for this exact order:

Two bugs the spec suite couldn’t see
Here’s the part worth being honest about. Before writing this post, I had 87 passing examples and multiple rounds of what I’d been calling “live verification” — JWT signing tested against a real Sandbox endpoint, a real quote returned for a real address, a full dispatch chain driven through DoorDash’s own Delivery Simulator. All of it genuinely worked. And a real order placed through the actual storefront, with DoorDash Delivery selected, still silently failed to dispatch — until minutes before I sat down to write this.
The order-completed subscriber was never actually registered with Spree’s event system. Spree::Subscriber’s own docstring says subscribers are “automatically registered during Rails initialization.” That’s not what spree_core 5.6.1 does — Spree::Events.register_subscribers! only ever iterates an explicit Spree.subscribers array, populated by appending to it in an initializer. There’s no Zeitwerk scan, no naming-convention discovery. spree_doordash never had that initializer line. Every single spec for OrderCompletedSubscriber called .call on it directly — which is a perfectly reasonable way to unit-test a subscriber’s logic, and also completely bypasses the actual registry the running application uses to decide which subscribers exist at all. The class was fully correct and entirely disconnected, the whole time. A real storefront order, placed with DoorDash Delivery selected, would complete successfully, charge nothing wrong, show a normal confirmation page — and never dispatch, with nothing anywhere suggesting a problem.
DoorDash rejects any phone number that isn’t strict E.164. Every previous live test in this project, across every milestone, happened to have phone numbers already typed as +1XXXXXXXXXX — because that’s how I was typing them into consoles and test fixtures. The first real checkout, with a number entered the way an actual address form produces one — (202) 555-0199 — got a genuine 400 Unknown phone number format back from DoorDash’s /drive/v2/quotes endpoint. That error doesn’t surface anywhere in Spree’s checkout UI; it just means the DoorDash Delivery rate silently doesn’t appear as an option, and a customer never knows delivery was available.
What both bugs have in common is the same root cause: a codepath being technically correct and reachable through the layer that tested it isn’t the same as being wired into the actual application. .call(event) works. DoordashQuote.new.compute_package(...) works. Neither one proves the app ever calls it. The fix, in both cases, was a real storefront order — placed through the browser, with the same kind of messy, human-typed input a real customer would produce — surfacing what months of unit and integration tests, run against the pieces individually, could not. Both are fixed now, in v0.1.1, with a regression spec that would have caught the registration bug specifically: expect(Spree.subscribers).to include(described_class).
This wasn’t the first time this project’s build log had that shape, either. Earlier milestones caught: DoorDash’s signing secret needing base64url decoding instead of standard base64; a .first on an ActiveRecord association returning stale cached data after a shipment was created via factory; DoorDash rejecting a stable per-order quote ID on re-quote with 409 duplicate_delivery_id; Postgres’s plain json type having no equality operator, breaking SELECT DISTINCT on every admin page with a JSON column. Every one of them: invisible to a fast, well-covered spec suite, and only found by actually running the thing against real infrastructure.
What it does today
- Live delivery-fee quoting at checkout — a real DoorDash quote, shown as an ordinary shipping rate, no custom frontend code.
- Dispatch on order completion — accepts the checkout-time quote (re-quoting first if the 5-minute window expired) and creates the real delivery, automatically, the moment an order completes.
- Delivery status synced back via webhooks — a Dasher’s pickup, drop-off, or cancellation updates the Spree shipment and order state without anyone watching a dashboard.
- Admin visibility — DoorDash Deliveries and DoorDash Webhooks pages, the same support-facing pattern
spree_squareestablished, so staff can see what dispatched and what didn’t without a console. - JWT-per-request auth — no OAuth dance; a
developer_id/key_id/signing_secrettriple, entered once and encrypted at rest, signs a fresh short-lived token on every call.
All of it verified against real DoorDash Sandbox infrastructure — including their Delivery Simulator driving a dispatched delivery through its full event lifecycle, DASHER_CONFIRMED through DASHER_DROPPED_OFF, and separately a DELIVERY_CANCELLED path — not just mocked specs.
Where this stands
DoorDash Drive’s production API access is currently restricted, with no committed timeline from DoorDash on approval — their own docs recommend pausing development unless an access request is already in. Everything in this post, and everything the extension does, is fully built and demoable against Sandbox right now; going live is gated entirely on DoorDash’s own approval process, not on any remaining engineering here.
The extension is public and free — MIT licensed, on GitHub and RubyGems, installable in any Spree store already running spree_square or standing alone.
If you’re running a Spree store and want delivery dispatch that doesn’t require trusting a black-box SaaS, or you’re evaluating DoorDash Drive and want to see the pattern (quote-then-accept, webhook-driven status sync, admin diagnostics) before building it yourself — reach out: [email protected] or @amitkssolanki on GitHub.
Working through something similar?