<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Toby Andrews</title>
    <description>The latest articles on DEV Community by Toby Andrews (@toby_andrews_68022e59cec7).</description>
    <link>https://dev.to/toby_andrews_68022e59cec7</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3964178%2Fdef29c79-f4cc-41ba-94d6-749cab3e1e1a.png</url>
      <title>DEV Community: Toby Andrews</title>
      <link>https://dev.to/toby_andrews_68022e59cec7</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/toby_andrews_68022e59cec7"/>
    <language>en</language>
    <item>
      <title>I reverse-engineered Tesco's API so an AI agent can do my grocery shop (and rank food by nutrition)</title>
      <dc:creator>Toby Andrews</dc:creator>
      <pubDate>Tue, 02 Jun 2026 08:48:07 +0000</pubDate>
      <link>https://dev.to/toby_andrews_68022e59cec7/i-reverse-engineered-tescos-api-so-an-ai-agent-can-do-my-grocery-shop-and-rank-food-by-nutrition-53ln</link>
      <guid>https://dev.to/toby_andrews_68022e59cec7/i-reverse-engineered-tescos-api-so-an-ai-agent-can-do-my-grocery-shop-and-rank-food-by-nutrition-53ln</guid>
      <description>&lt;p&gt;Tesco — the UK's biggest supermarket — has no public API. I wanted to automate my own weekly shop, eventually hand it to an AI agent, and get at the one thing every grocery site has and none of them let you query: &lt;strong&gt;nutrition data&lt;/strong&gt;. So I built &lt;a href="https://github.com/tobyandrews1985/basketeer" rel="noopener noreferrer"&gt;basketeer&lt;/a&gt;, a typed TypeScript SDK for a personal Tesco account.&lt;/p&gt;

&lt;p&gt;Here's the part that surprised me most:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuatbyicov9zza8q4t1nm.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuatbyicov9zza8q4t1nm.gif" alt="Filtering and ranking a live Tesco search by nutrition, then reading a product's micronutrients" width="800" height="369"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# "high-protein yogurt, &amp;gt;=10g protein, &amp;lt;=7g sugar, ranked by protein" — live, no login&lt;/span&gt;
basketeer search &lt;span class="s2"&gt;"high protein yogurt"&lt;/span&gt; &lt;span class="nt"&gt;--min-protein&lt;/span&gt; 10 &lt;span class="nt"&gt;--max-sugar&lt;/span&gt; 7 &lt;span class="nt"&gt;--sort&lt;/span&gt; protein
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Not scraping — a GraphQL gateway
&lt;/h2&gt;

&lt;p&gt;Most "Tesco API" projects scrape the HTML and shatter the next time the site is restyled. But Tesco's own website talks to a GraphQL gateway at &lt;code&gt;xapi.tesco.com&lt;/code&gt;. If you speak that protocol directly, you get a stable, structured data plane that a cosmetic redesign doesn't touch. The only load-bearing header for reads is a public &lt;code&gt;x-apikey&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the whole catalogue side — search, product lookup, browse — is plain &lt;code&gt;fetch&lt;/code&gt;. No browser, stateless, polite 1 req/s.&lt;/p&gt;

&lt;h2&gt;
  
  
  The nutrition goldmine
&lt;/h2&gt;

&lt;p&gt;The product endpoint returns the on-pack nutrition table. Raw, it's a mess: energy split across two rows (&lt;code&gt;"257 kJ/"&lt;/code&gt; then &lt;code&gt;"61 kcal"&lt;/code&gt;), comma decimals, footnote markers, label aliases (&lt;code&gt;"of which sugars"&lt;/code&gt;, &lt;code&gt;"salt equivalent"&lt;/code&gt;). basketeer normalizes all of it into a typed model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;searchByNutrition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;high protein yogurt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;protein&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;sugars&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;protein&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;desc&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;macros&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// { energyKcal, protein, fat, saturates, carbs, sugars, fibre, salt }&lt;/span&gt;
&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;nutrition&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;micros&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// [{ name: "Calcium", amount: 120, unit: "mg", nrvPercent: 15 }, ...]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Macros &lt;em&gt;and&lt;/em&gt; structured micronutrients — per vitamin and mineral, with amount, unit, and % of the Nutrient Reference Value — and it's all on anonymous reads. That's a meal-planning dataset hiding in plain sight.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard part: auth
&lt;/h2&gt;

&lt;p&gt;Reads are free. Anything tied to your account needs a session, and that's where it gets interesting: Tesco's sign-in sits behind Akamai (TLS fingerprinting + a JS challenge) that only a genuine browser satisfies. So basketeer mints the session once with a real Chrome (via Playwright), harvests the bearer token + cookies, and from then on every call is pure HTTP. The session lasts ~30 days; the ~1-hour access token refreshes through the same browser path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Letting an agent shop
&lt;/h2&gt;

&lt;p&gt;Because there's one clean typed core, putting a CLI and an &lt;strong&gt;MCP server&lt;/strong&gt; on top was easy. The MCP server lets Claude Desktop (or any MCP client) run the shop. The catalogue + nutrition tools work with zero auth, so an agent can search and rank by nutrition out of the box.&lt;/p&gt;

&lt;p&gt;The safety model matters here: read-only tools are annotated &lt;code&gt;readOnlyHint&lt;/code&gt;, mutating ones &lt;code&gt;destructiveHint&lt;/code&gt;, and the irreversible ones (cancel an order, check out) are &lt;strong&gt;two-step&lt;/strong&gt; — the first call returns a preview and a confirm token, and you only proceed by calling again with that token. And &lt;code&gt;checkout()&lt;/code&gt; deliberately stops at the payment URL. There is no "pay" tool — 3-D Secure is browser-bound and fraud-sensitive by design, so a human always finishes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stops, on purpose
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Payment&lt;/strong&gt; is out of scope. &lt;code&gt;checkout()&lt;/code&gt; fills nothing and pays nothing; it returns the URL where you finish.&lt;/li&gt;
&lt;li&gt;It's &lt;strong&gt;UK-only&lt;/strong&gt; and &lt;strong&gt;reverse-engineered&lt;/strong&gt; — it can break if Tesco changes the gateway.&lt;/li&gt;
&lt;li&gt;It's for automating &lt;strong&gt;your own&lt;/strong&gt; account, in the spirit of personal interoperability. Not scraping at scale.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;basketeer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Basketeer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;basketeer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Basketeer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;              &lt;span class="c1"&gt;// no auth for catalogue + nutrition&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;oat milk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MIT, zero deps in the importable core, 71 tests, CI on Node 18/20.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/tobyandrews1985/basketeer" rel="noopener noreferrer"&gt;https://github.com/tobyandrews1985/basketeer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/basketeer" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/basketeer&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've reverse-engineered a retailer's private API, or you're wiring grocery data into an agent, I'd love to hear how you approached auth and the inevitable schema drift.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>opensource</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
