Why Pennyfill gets its fuel prices by email
October 13, 2026
FuelFinder, my weekend vanilla JS project, has grown up a lot since I last wrote about it. It’s now called Pennyfill and lives at pennyfill.co.uk, with a Vue front end, Supabase behind it and an Android app on the way. That’s a post for another day. This one is about something smaller that caught me out: where the prices come from.
The API I couldn’t use
The UK government’s Fuel Finder scheme publishes forecourt prices as open data, with an API and a CSV. I built the import against the API, scheduled it from Supabase, and it didn’t work. On 28 September Fuel Finder support confirmed why: the API only accepts verified static IP addresses on UK fixed-line networks. Not cloud hosting, and not a relay in front of cloud hosting either.
Pennyfill has no server sitting on a UK broadband line, and I wasn’t going to run one under my desk. Support pointed me at the CSV instead. It’s published twice a day, at about 09:00 and 15:00 UTC, and there’s an email subscription that sends a link every time a new file is out.
So the prices arrive by email
It feels a bit backwards, but it works. The Fuel Finder emails go to [email protected], which is a Cloudflare Email Worker rather than a mailbox. For each email it:
- forwards a copy to me, so anything unexpected still reaches a person;
- checks it came from gov.uk, pulls the links out of it and downloads the first one that turns out to be the CSV;
- posts the file to the same Supabase import function the admin upload uses;
- records what happened, so the admin page shows a missed file.
The link-finding is deliberately dull. It ranks links ending in .csv or pointing at file storage first, skips unsubscribe and privacy links, only downloads from government or Amazon S3 hosts, and only accepts a file whose first column header is forecourt_update_timestamp. It never tries the website’s download button or the API.
Anyone can send an email
The obvious problem with an email inbox that writes prices into a database is that anyone can email it. Checking the From address is pointless, as that’s trivial to fake. A DKIM signature isn’t, so the Worker only imports the file if there’s a passing DKIM signature from a gov.uk domain.
My first version of that check had a hole in it. Cloudflare records its DKIM result in an Authentication-Results header on top of the message, but a sender can also write their own Authentication-Results header into the email they send. If the code looked for any header that said dkim=pass for gov.uk, a forged email could just claim it. The fix was to trust only the topmost one, and only if Cloudflare wrote it:
const top = headers.find((h) => h.key.toLowerCase() === 'authentication-results')
const auth = (top?.value ?? '').toLowerCase().replace(/\s+/g, ' ').trim()
if (auth.split(';')[0]!.trim() !== 'mx.cloudflare.net') return { ok: false, auth }
That check now lives in its own small file with tests, and it went in alongside a GitHub Actions workflow that runs lint, type checks, tests and the build on every push.
Two days without prices
The other lesson was more embarrassing. Forwarding a copy to me came first in the Worker, and it wasn’t wrapped in anything. When the forward failed, because the forwarding address wasn’t set up as a verified destination, it threw, the whole delivery failed, and the import never ran. That started on 29 September and no CSV was imported for two days.
The forward is now in a try/catch. If it fails, the error is reported along with the import result, and the import carries on. The copy to me is a nice-to-have; the prices are the whole point of the site, so a failure in the nice-to-have shouldn’t take them down.
There was one more wrinkle. On 1 October I moved the subscription to my own Gmail and set a filter to forward the emails to the Worker. Gmail rewrites the envelope sender when it forwards, so the Worker falls back to the From header as a first filter (the DKIM check still does the real work), and it skips sending a copy back to me when the email came from my inbox, otherwise it would loop round my filter forever.
What I’d tell myself a month ago
- Read the access rules for an API before building against it. “Open data” doesn’t always mean “callable from anywhere”.
- If an email can trigger a write, assume someone will send that email on purpose.
- Put the optional steps after the important one, or make sure they can’t stop it.
Twice a day, a government email lands, a Worker reads it and the map updates. Not the architecture I planned, but unlike that one, it is allowed to run in the cloud.