Scrape Bolt Food restaurants, stores and full menus with prices across 260 cities in 16 countries. Search by keyword, list every venue delivering to an address, or pull full menus from venue URLs. Every menu row carries its venue, city and rating. Clean JSON, no browser.
All notable changes to this Actor are documented here.
This project adheres to Semantic Versioning .
[0.1.0] - 2026-08-24
Initial release — the first Bolt Food scraper on the Apify Store.
Added
Five scrape modes, with menus part of the mode rather than a separate toggle, so the dropdown
states what a run produces. searchMenus (the default) is a full crawl: keyword search via
cityWideSearch, then every matching venue's menu.
searchMenus — keyword search across a city, then the full menu of every match.
discoveryMenus — the whole venue feed for a location, then every menu.
venueUrls — venue details plus the full menu for specific venue URLs.
search — keyword search, venue rows only.
discovery — the whole venue feed, venue rows only.
Internally a mode splits into a venue source and a menu decision, so no handler special-cases a
mode name. The retired includeMenus toggle still turns menus on for the two venues-only modes,
so inputs saved before the split keep working.
Menu rows carry their venue. Beyond venueId/venueSlug/venueName/url, each menu item
also carries venueCity, venueCountry, venueAddress, venueRating and the searchQuery that
surfaced the venue — so a billed CSV export answers cross-venue price questions without a second
lookup, and a multi-keyword run stays attributable.
Full menu extraction with prices. One request returns a restaurant's entire menu tree;
dishes are flattened into rows carrying price, currency, pre-promotion price, discount flag,
Bolt's formatted price string, availability, popularity, selling unit, category path and the
number of customization option groups and options.
Bundled city registry of 260 Bolt Food cities across 16 countries (AZ, BG, CY, CZ, EE, GE,
GH, KE, LT, LV, MT, PL, PT, RO, SK, UA), each verified against the live API. City names match
ignoring case and accents; an unknown name fails the run immediately with "did you mean"
suggestions instead of scraping the wrong place.
Address-level targeting.city picks the market, the optional address narrows the delivery
point inside it, and latitude/longitude target any exact point. Because Bolt Food's venue
list depends on the delivery address, this changes which venues, fees and ETAs you get.
Search result enrichment.cityWideSearch returns no address or coordinates, so search mode
loads the city feed once per run and back-fills address, coordinates, exact rating, rating count
and price tier onto every search hit — one extra request for the whole run.
Optional store-catalog expansion.expandCatalogs walks into supermarket category pages
(bounded at 60 follow-up requests per venue). When it is off, the run ends with a warning naming
the venues whose catalogs were only partial, so partial data is never reported as complete.
One dataset. Everything goes to the run's default dataset and every row there is a billed
result — menu items in the menu modes, venue records in the venue-list modes, never a mix. There
are no named datasets, for two reasons. Handing venue records out free alongside menus undercut
the venue-list modes, which bill per venue: a run with maxItemsTotal=1 bought one menu item and
took a whole city's venue records with it. And a published Actor runs under Apify's
LIMITED_PERMISSIONS, where it may only touch its own default storages — creating a named dataset
returns HTTP 403, which failed the request that was writing venue rows, meant no menus were ever
queued, and ended the run with zero results. Failed requests are reported in the run log.
No default caps.maxVenues, maxItemsTotal and maxMenuItemsPerVenue all default to 0,
meaning no limit: a run returns everything it finds rather than silently truncating. Set any of
them to bound a run. Input validation still fails before the first request, so invalid input can
never bill a dataset item.
Apify Proxy always on, datacenter only, nothing to configure. The proxy input is gone.
Residential is not selectable, and a residential group passed through the old input shape is
dropped with a warning: it is billed by bandwidth, and on a pay-per-result Actor that bandwidth is
paid by the Actor's owner rather than by whoever runs it. Nothing is lost by restricting it — Bolt
serves datacenter IPs normally under load, and rate limiting is keyed on the client identity
rather than the IP. Country pinning is not offered either: measured against a datacenter pool,
only US resolved while RO, DE and NL failed every request outright, and none of Bolt Food's 16
markets are covered — datacenter without a country reached all 16.
Every row carries runId, so exports from several runs can be merged and split apart again —
which is what tracking prices over time needs. Null on a local run.
154 fixture-driven unit tests covering envelope parsing, error classification, mappers, the menu
flattener, catalog-preview detection, city resolution, URL parsing, client-identity rotation,
request building and input validation.
Implementation notes
HttpCrawler on apify/actor-node:20 — no browser. A city venue list is one request, and a
restaurant's whole menu is one more; a measured run took 61 requests and 5 seconds to pull 60
venues with 3,107 priced menu items.
Bolt Food's API needs no authentication, only client-fabricated device parameters — and that
identity is what it rate-limits on, not the IP. Measured from a single IP with no proxy: reusing
one deviceId had 12–50% of requests rejected with HTTP 429 at ~18 req/s, while a fresh
deviceId per request completed 600 requests at 126 req/s with none rejected. Varying the
provider id changed nothing; only the identity did. So the identity is stamped on in the
crawler's preNavigationHook, which means every retry also gets a new one — retrying with the
identity that was just limited is the one case guaranteed to fail again. Keeping it out of
request.url also keeps queue keys and error logs readable.
Errors arrive inside HTTP 200 bodies. Every response is a { code, message, data } envelope,
so HTTP status alone never indicates success. Responses are classified by envelope code:
TOO_MANY_REQUESTS (1005) is retried, while NOT_EXISTS (400), INVALID_REQUEST (702),
CITY_NOT_FOUND (5812) and DELIVERY_NOT_AVAILABLE (5816) are terminal and skip retries.
Menu item tags carry Bolt's attribute keys (spicy, vegan, vegetarian, alco, ...). Bolt
returns these as objects, not strings, so a string-only filter silently dropped every one; about
5-9% of dishes carry at least one. Venue records genuinely have no tags — 0 of 2,539 providers
sampled across six countries returned any — so venue rows stay empty by nature, not by omission.
Text from Bolt is stripped of markup before output. Merchants paste HTML into Bolt's own fields and
Bolt serves it back verbatim, so <b>, <br>, <span> and were reaching the dataset;
tags now become spaces and entities are decoded.
Prices are decimal numbers already in major currency units — verified across EUR, PLN, CZK, RON,
UAH, KES, AZN and GEL — so no minor-unit conversion is applied.
Result slots and the venue budget are claimed before awaiting a push, so concurrent handlers
cannot together overshoot the user's result cap.
Reaching the result cap aborts the crawl instead of merely skipping the remaining handlers.
Queued requests were previously still fetched so their handler could drop the response: a capped
Prague run made 258 requests of which 16 were productive, taking 161s. It now makes 25 requests in
13s for the same 1000 rows — a 10x cut in requests, run time and compute units.
HTTP 403 stays retryable (IP-level block, recoverable by session or proxy rotation) while
400/401/404/410 are terminal.
Caps are represented internally as Infinity when unset, so the cap comparisons in routes.ts
work unchanged whether or not the user set one.
Because the rate limit is per identity, no throttling is imposed anywhere: the same supermarket
catalog run that lost 8 of 30 requests to HTTP 429 under a single shared identity completes all
30 first try, 7x faster, with rotation.
BOLT_APP_VERSION pins the web-app version the API gates on (FW.1.116, captured 2026-08-24).
If every request starts failing at once, that constant is the first thing to bump.