Field Report // NO. 019

I built a skill that orders food on Deliveroo

24 August 2026· 7 min read· Case № 019

Deliveroo has no consumer ordering API and its login sits behind Cloudflare Turnstile. I built a skill that browses, adds to basket, and places an order anyway, using a real Chromium browser through a WARP split tunnel. Here is the reproducible pattern and the human-confirm gate that makes it safe.

I have a skill that orders food on Deliveroo. I say “I” but the agent does the work: it logs in, browses a restaurant, adds items to the basket, reads the basket back to me, and waits for my approval before it clicks “Place delivery order”. It has been live since the end of July, and it cost nothing to build or run.

This is the story of how it works, what broke along the way, and the one design decision that makes it worth trusting at all.

The real problem

Deliveroo has no consumer ordering API. The partner APIs that exist are for restaurants and businesses dispatching via Deliveroo couriers, not for people who want food. There is a public GraphQL endpoint, and it is genuinely public, but the data queries are rate-limited hard from datacenter IPs. Even through a proxy, search and text queries come back with “Try again in a moment”. And the basket operations are not in the GraphQL schema at all. They live in a separate REST API that requires authentication.

So there was no clean path. The only way to order food programmatically was to drive the website like a person.

That is where the second wall appeared. Deliveroo sits behind Cloudflare, and its login page throws a Turnstile challenge that detects headless browsers. A datacenter IP has a low success rate against Cloudflare’s detection stack. The login page would not even render.

What was built

The working path is browser automation with a real browser. I use nodriver, which launches actual Chromium with the automation signals patched out, running under xvfb so it has a virtual display. That combination gets past Turnstile and renders the full login form. For general browsing, the traffic routes through a Cloudflare WARP split tunnel, so only the Deliveroo domains go through WARP and everything else stays on the normal IP. The two layers stack: nodriver’s Chromium routes through the WARP IPs automatically, so the same browser that clears Turnstile on login also gets through on the menu pages.

For reading data, I do not fight the rate-limited GraphQL API. Deliveroo is a Next.js app, and Next.js apps embed their full state in a __NEXT_DATA__ script tag on the page. The browser reads that structured JSON directly. Restaurant names, ratings, delivery fees, minimum order values, prices, all of it, without touching the API at all.

The reproducible pattern

The ordering flow is a fixed sequence, and it is worth writing down because every step is load-bearing.

Login is OTP-based. Deliveroo sends a six-digit code to the phone, and each login attempt invalidates the previous code. So the script keeps the browser alive and polls a file for the code rather than re-running the login while waiting. One attempt, one code, done.

The viewport has to be mobile. Deliveroo’s checkout only renders correctly at 390x844. At desktop sizes, the “View basket” and “Go to checkout” buttons do not respond. This was the first thing that broke and the fix was a single line, but it took a while to find.

Adding items is a real click on the item, then the “Add for £X” button. The basket subtotal updates, and “Go to checkout” becomes active.

Then the flow hands control back to a human. The agent reads the basket contents back and waits. Only after I approve does it continue.

What broke and how it was fixed

Three things broke, and each one taught a lesson about driving React apps with a browser.

The first was Turnstile. The Browserbase browser tool could not get past it. nodriver with a real Chromium and a virtual display could. That is the whole reason the auth path uses nodriver and not the browser tool.

The second was the viewport. Desktop sizes broke checkout rendering entirely. The fix was forcing the mobile viewport.

The third was the most interesting. “View basket” is a React button, and calling .click() in JavaScript does not trigger the React handler. The button has to be clicked with a real mouse event at coordinates. page.mouse_click(x, y) works. This is a general lesson for anyone automating React sites: if a programmatic click does nothing, try a real mouse click at the element’s position.

There is also a session quirk worth knowing. The basket is bound to the browser session, not to the account. Items I add do not appear in my phone app. That sounds like a problem, but it is not, because the agent completes the whole checkout from its own session. The basket, the payment page, the order, all in the same browser.

Between the basket and the payment page there are upsell prompts, and the agent has to click through them, up to eight times, before the payment page loads. It is tedious but it works.

The money gate

Here is the part that makes this safe, and it is the part I would not ship without.

The agent never places an order unattended. The confirmed workflow is: I ask for food, the agent adds the items, the agent reads the basket contents back to me, I approve, and only then does the agent click “Place delivery order”. The human-confirm step is not a nicety. It is the load-bearing safety feature.

That matters because the agent is spending real money on my behalf. A basket that silently contains the wrong item, or the wrong quantity, or the wrong restaurant, is a real cost. The confirmation step catches all of that before any money moves. If you build something like this, make the human gate non-negotiable. Never let the agent complete a purchase without explicit approval of the exact basket contents.

Cost and operational burden

The whole thing runs on free infrastructure. nodriver and xvfb are free. WARP split-tunnel is free. There is no per-order cost, no API credits, no subscription. The operational burden is a one-time setup: install Chromium and xvfb, configure the WARP split tunnel so only the Deliveroo domains route through it, and keep the browser profile persistent so the login survives.

The one maintenance risk is that Deliveroo changes its site and the selectors break. That is the nature of browser automation. When it happens, the fix is to re-inspect the page and update the clicks. It is not zero maintenance, but it is cheap.

Honest limits

I want to be clear about what this is and is not.

First, the terms of service. Deliveroo’s terms prohibit automated account ordering. This skill operates in a grey area. I built it for my own use, on my own account, with a human confirming every order. I am not recommending you run unattended automated ordering, and I am not claiming this is something Deliveroo endorses. It is a personal automation that works today and could break or be blocked at any time.

Second, the basket is session-bound. There is no app sync, no way to hand the basket to a phone. The agent has to complete the order from its own session, which means the human-confirm step happens in the agent’s flow, not in the app.

Third, it is fragile by design. It depends on the site’s DOM, on Cloudflare’s current detection stack, on the WARP IPs staying unblocked. Any of those can change. This is not a robust integration. It is a working automation that I maintain.

The takeaway

The useful lesson is not that you can order food with an agent. It is the pattern: when a service has no API and sits behind Cloudflare, a real browser through a split tunnel, reading the SSR state instead of the API, is a viable path. And when that path spends money, the human-confirm gate is what makes it worth running at all.

The agent does the tedious work. I do the one thing that matters: I look at the basket before the money moves.

← All transmissions