Writing

I wrote the integration checklist. Then I ignored it.

September 23, 2026 · Amit Solanki

In August I published a checklist of ten things I wish I had known at my first API integration, drawn from 200+ of them. Its first section is called “The assumption you don’t know you’ve made.”

Two weeks after that I published the DoorDash Drive integration in which I had made exactly that assumption, repeatedly, and had no idea until I built the second one.

Adding Uber Direct as a second delivery provider should have been mechanical: delivery is just another Spree::ShippingMethod with a calculator that calls a real API instead of doing arithmetic, dispatch happens on order.completed via an event subscriber, status syncs back through webhooks. Same shape, different API. Some of it was.

The parts that weren’t are worth writing about, because every one of them was a place where the DoorDash integration had quietly encoded a DoorDash-specific assumption as if it were a delivery-integration assumption in general. Auth. Location identity. Sandbox behaviour. Webhook payload shape. None of those are obscure. They are four of the axes on my own list.

Code’s here: github.com/amitkssolanki/spree_uber_direct · rubygems.org/gems/spree_uber_direct.

Auth: a cached bearer token, not a JWT signed per request

DoorDash Drive auth is a developer_id/key_id/signing_secret triple that signs a fresh, short-lived JWT on every single API call - there’s no token to cache, because there’s no token that outlives one request. I’d internalized that as “how delivery-API auth works” without noticing it was actually “how this delivery API’s auth works.”

Uber Direct uses OAuth2 client_credentials against auth.uber.com/oauth/v2/token, which issues a real bearer token with an actual expiry - the kind of token that’s supposed to be reused across requests, not reissued for each one. That meant SpreeUberDirect::Client needed something SpreeDoordash::Client never had to: a place to cache the token and a check for when it needs refreshing.

# SpreeUberDirect::Credential - a cached access_token + access_token_expires_at,
# with a margin check so refresh happens before the token actually dies, not after.
def needs_refresh?
  access_token.blank? || access_token_expires_at.blank? ||
    access_token_expires_at <= REFRESH_MARGIN.seconds.from_now
end

The token lives on the Credential row itself rather than in a new caching layer - this project has no Redis anywhere, and a database column is a perfectly good place to cache one value per store. The one thing worth being deliberate about: the client resolves its credential fresh per instance rather than memoizing it, specifically so that if an admin rotates the client secret, the very next call picks up the change instead of running on a stale cached token until it happens to expire on its own.

No location to register - because there’s nothing to register

DoorDash’s Drive API has a real concept of a registered “Store” - you create one via their API, map it to a Spree::StockLocation, and every quote/dispatch call references that store id. spree_doordash has a whole LocationMapping table for exactly this.

Uber Direct has no equivalent concept at all for a single-customer-id integration like this one. Pickup is just an address, passed directly on every quote and delivery call - there’s no “register your store first” step anywhere in their API. spree_uber_direct has no LocationMapping table, and I want to be specific about why that’s not an oversight: I went looking for the equivalent concept, confirmed against Uber’s own API docs that it doesn’t exist, and then didn’t build a table for something with nothing to map. The tempting failure mode here is copying the sibling extension’s structure because it worked there - five models became four because the fifth would have mapped an id to itself.

Uber has no delivery simulator - it has a fake courier you have to explicitly ask for

DoorDash’s sandbox has a Delivery Simulator: a dashboard button that walks a dispatched delivery through its real event lifecycle so you can watch webhooks arrive without a driver. Uber Direct has no dashboard equivalent. Its version - Robo Courier - isn’t a UI at all; it’s a flag on the Create Delivery API call itself.

Without it, a sandbox delivery fires exactly one webhook (its initial pending status) and then goes silent forever, because no real driver app is ever going to pick it up. The fix is a test_specifications object on the request:

payload[:test_specifications] = { robo_courier_specification: { mode: 'auto' } } if robo_courier

Set it, and Uber’s own bot walks the delivery through real transitions - assigned, en route, pickup imminent, picked up, dropoff imminent, delivered - firing a real webhook at each stage on a fixed cadence. Confirmed live: without this, an order dispatched through the actual codebase stalled at pending with nothing else ever arriving. With it, the full lifecycle played out exactly as documented.

The gate I added, then deliberately took back off

Here’s the part I think is actually the most useful lesson in this whole post, and it’s not a bug

  • both versions of this code were correct for what they were guarding against.

The first version gated Robo Courier on client.sandbox? alone - request the fake courier whenever the store’s credential says sandbox. Claude caught a real problem with that in review: this project’s own production deployment runs its real, live storefront against a genuinely uber_environment: sandbox credential, because Uber hasn’t granted production API access yet. Gating on the credential alone meant that if this ever shipped as-is, every real visitor’s real delivery on the live production site would get silently auto-advanced through fake status transitions on a 30-second timer - not a hypothetical, exactly what would happen given exactly how this project is actually deployed. The fix added a second, independent check:

def robo_courier?(client)
  client.sandbox? && !Rails.env.production?
end

That shipped, was correct, and had a regression spec proving it. Then, two commits later, I took it back out.

The reasoning: this specific project’s storefront is a public demo, running end-to-end on sandbox credentials - Square, DoorDash, and Uber all at once - regardless of what Rails.env says. Rails.env.production? being true here doesn’t mean “a real customer is about to lose real money if this goes wrong.” It means “this is the deployed instance a real visitor might click through, on a system that is a demo end to end.” Showing that visitor the full Uber Direct delivery lifecycle live is the actual intended experience of this specific project, not the accident the earlier safety commit assumed it would be. So the gate went back to client.sandbox? alone - with a comment explicitly naming the earlier reasoning, why it was right for its own assumptions, and why this project’s actual shape makes a different call correct.

I’m including the reversal, not just the fix, because “add a safety check” is the easy half of this story. The harder half - and the one that doesn’t get written about nearly as often - is noticing that a safety check encoded an assumption about what kind of system it was protecting, checking whether that assumption is actually true for this system, and being willing to take the check back out with the reasoning left in the comment instead of just quietly reverting it. A safety gate is only as good as the model of the world it assumes; get the model wrong in either direction and the gate does the wrong thing confidently.

A GPS ping and a refund notification, both missing the one field the code assumed

DoorDash sends one webhook per URL. Uber Direct lets a single webhook subscription cover three different event kinds on the same endpoint: event.delivery_status (an actual delivery state change), event.courier_update (a courier’s GPS location, fired roughly every 20 seconds once someone’s assigned), and event.refund_request (a refund notification). The webhook handler was written against the first kind and assumed every payload would look like it - specifically, that every payload would carry a status field, because that’s the only kind of Uber webhook the handler’s own model, WebhookEvent, was built to store.

event.courier_update and event.refund_request don’t have a status field. At all. The handler’s find_or_create_by! call built a WebhookEvent with status: payload['status'], hit a nil, tripped WebhookEvent’s presence validation, raised RecordInvalid - which an existing rescue block (written for a genuine race on duplicate deliveries) misread as “the row already exists, go find it,” found nothing, and raised RecordNotFound unrescued. The net result, verified live through a real ngrok-tunneled dispatch: every single courier-location ping Uber sent during a real Robo Courier run came back as a 404.

The fix isn’t clever - acknowledge and drop anything with no status before it reaches the model that requires one:

if payload['status'].blank?
  Rails.logger.debug { "[SpreeUberDirect] dropping webhook with no status (kind=#{payload['kind']}, id=#{payload['id']})" }
  return head :ok
end

What’s worth naming is the shape of the original bug: the handler wasn’t wrong about event.delivery_status payloads, and it wasn’t obviously wrong in a way a quick read would catch

  • it just quietly assumed the one payload shape it was built and tested against was the only shape that would ever arrive on that URL. A single endpoint fanning out to multiple event kinds is common enough (this same shape shows up in Stripe, GitHub, and most other webhook-fan-out providers) that “does every subscribed event kind actually carry the field my handler keys off of?” is worth checking explicitly, not assumed from the one event kind you tested against first.

What it does today

  • OAuth2 client_credentials auth, cached bearer token with expiry-aware refresh - the one genuinely different piece of plumbing from the JWT-per-request sibling.
  • Live delivery-fee quoting, same zero-frontend-code Spree::ShippingCalculator pattern as DoorDash - both providers’ rates appear side by side at checkout automatically.
  • Dispatch on order completion, with Robo Courier walking sandbox deliveries through a full real lifecycle instead of stalling at pending.
  • Webhook status sync, now correctly acknowledging and dropping the two non-status event kinds instead of 404ing on every courier GPS ping.

Verified live on the actual production deployment, both providers quoting the same real order: Uber Direct Delivery at $7.99, DoorDash Delivery at $9.75.

Where this stands

Uber Direct’s own onboarding is more self-serve than DoorDash’s - sandbox access is immediate, no approval queue - but production API access is still approval-gated, same as DoorDash, on a timeline Uber doesn’t commit to either. Everything here is fully built and demoable against Sandbox right now, Robo Courier included; going live is gated on Uber’s approval process, not on any remaining engineering.

The extension is public and free - MIT licensed, on GitHub and RubyGems, installable standalone or alongside spree_doordash in any Spree store.

The wider platform both extensions run in - Square POS as the source of truth, both courier networks quoting head-to-head at checkout - is written up in the case study.

What actually changes after a hundred of these

The tidy version of this post is that the second integration is what tests your abstraction. It is a good line and I do not believe it.

At Checkmate I led the integrations organisation through 130+ production integrations, across 25+ POS systems and 30+ delivery platforms. Nobody there learned this on the second integration. Not because anyone was smarter, but because an integrations org has already paid for the list of what varies between vendors, and checks it every single time. Auth. Location. Sandbox fidelity. Webhook shape. Status vocabulary. None of it is a discovery.

So what changed here was not my knowledge. It was the stakes. spree_doordash is a side project, written fast, and I generalised from one implementation because doing it properly costs a day I did not feel like spending on a gem nobody was paying me for. That is the whole mechanism. The discipline is not something you learn once and keep. It is something you apply when the cost of not applying it feels real, and a weekend gem never feels real.

Which is the uncomfortable part, because the checklist was right there. I wrote it. I published it seven weeks earlier. It did not help, because the thing that fails is not recall.

Working through something similar?

← All writing