FoodHero Scraper - Grocery Deals, Prices & Stores avatar

FoodHero Scraper - Grocery Deals, Prices & Stores

Pricing

from $1.20 / 1,000 deal or store records

Go to Apify Store
FoodHero Scraper - Grocery Deals, Prices & Stores

FoodHero Scraper - Grocery Deals, Prices & Stores

Scrape FoodHero surplus grocery deals across Canada by area, store or offer ID. Extract products, brands, regular and discounted prices, savings, portions left, best-before dates and CO2 savings, plus store names, chains, addresses, coordinates and types.

Pricing

from $1.20 / 1,000 deal or store records

Rating

0.0

(0)

Developer

Abot API

Abot API

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

10 days ago

Last modified

Security

Limited permissions

Share

FoodHero Scraper: Surplus Grocery Deals in Canada

FoodHero Scraper turns FoodHero into a clean surplus-grocery data feed. FoodHero is the Canadian app that helps grocery stores sell surplus food before it goes to waste, and this actor returns one row per deal: the product, both prices, the discount, how many portions are left, the best-before date, the CO2 saved, and the store behind it. Search any Canadian area, or paste a store or offer id, then export to JSON, CSV or Excel, or pull the results straight into your app through the API.

Why This Scraper?

  • Two ways to find deals. Search any Canadian area by coordinates or place name, or paste a store or offer id directly. Leave the area empty to cover every FoodHero city in Canada at once.
  • Full deal economics on every row. Regular price, deal price, discount percent, portions left, best-before date and CO2 saved, all on one flat record.
  • A pasted store returns something. Every deal it lists right now (a store with many deals returns many rows, and Max items bounds how many), or a single store row when it currently has none, so a store you named that FoodHero still lists does not come back empty just because it has no surplus today.
  • Optional store enrichment. In search mode, add each store's phone and website to every row, only when you turn it on, so a plain run stays fast.
  • Built for schedules. Incremental mode returns only new, changed and reappeared deals on recurring runs, and EXPIRED rows are only produced after a run that read every area to the end without a limit or a read error stopping it (see the notes under "Resume and recurring updates").
  • Resume without paying twice. Point Resume at an interrupted run and the deals it already saved are skipped.
  • Pipe results into your apps. Optionally send a summary of each deal into Notion, Linear or Airtable through MCP connectors, as a delivery step that never changes the dataset.

Use Cases

  • Deal-hunting apps and browser extensions: surface today's steepest surplus discounts for a city or a shortlist of favourite stores.
  • Food-waste and sustainability research: track CO2 saved and portions rescued across Canadian grocery chains over time.
  • Price and discount monitoring: watch a handful of stores every morning and get back only what is new or has changed.
  • Retail and chain analytics: compare discount depth, sell-through and department mix across banners and provinces.
  • Local deal roundups and community boards: pull today's best-before-soon bargains near a neighbourhood.

Data You Get

Sample shape: values are illustrative placeholders, not from a live record.

FieldExample
recordType"offer" (also "store" for a pasted target that lists nothing right now)
offerId"64f1a2b3c4d5e6f7a8b9c0d1"
urlFoodHero app link to the offer or store
productName"Frozen Berry Medley"
productBrand / productDescription"Sample Orchards" / "Mixed berries, family pack"
productSize / departmentName"600 G" / "Frozen"
price / discountPrice12.99 / 6.49
discountPercent50
currency"CAD"
itemLeft4
isSoldOut / isLastCallfalse / false
isNew / isFrozentrue / true
bestBefore / bestBeforeAt1798761600 / "2027-01-01T00:00:00+00:00"
co2Volume0.12
productImageUrlphoto of the product
storeId / storeName"64f1a2b3c4d5e6f7a8b9c0aa" / "Sample Grocery Market"
storeType / chainId"Grocery store" / "64f1a2b3c4d5e6f7a8b9c0bb"
address / city / state / postalCode"12 Sample Street, Springfield, ON" / "Springfield" / "ON" / "K1A0B1"
latitude / longitude / distanceKm45.42 / -75.7 / 2.3
phone / websitestore phone and website (search mode: only with store details on; URL mode: always)
openingHours / pickUpInstructionsweekly hours as [{"weekday": 0, "from": 600, "to": 1140}] (minutes after midnight, weekday 0-6) / the store's pick-up note (same availability as phone; empty when the store lists none)
productCount / storeOfferCount180 / 42
changeType / changedFields / firstSeenAt / lastSeenAt"UPDATED" / ["discountPrice"] / first and last seen timestamps (incremental mode only)

A pasted store that lists no deal right now comes back as a single "store" row carrying the same store columns, with the deal-only columns empty.

The Output tab offers three tables: Results (every row), Stores (store rows) and Changes since the last run (incremental mode: change type and changed fields). Each run also saves a short run summary under the key-value store record OUTPUT: records returned, counts per record type and change type, rejected requests and whether the run stopped at Max items.

How to Use

  1. Pick a mode: search deals by area, or paste store or offer ids.
  2. For search mode, list one or more areas as place names or lat,lng pairs, set a radius, and optionally filter by store name. Leave the area list empty to cover every FoodHero city in Canada at once.
  3. Turn on Include sold-out deals, or the store-details option to add each store's phone and website in search mode, if you need them, and set Max items (and, optionally, Max pages per area) to size the run.
  4. Click Start, then download the dataset as JSON, CSV or Excel, or read it through the API.

Search deals in a couple of cities:

{
"mode": "search",
"locations": ["Toronto", "Vancouver"],
"radiusKm": 15,
"maxItems": 100
}

Every deal at one store, by id (URL mode):

{
"mode": "url",
"urls": ["store/aaaaaaaaaaaaaaaaaaaaaaaa"]
}

If you pick URL mode and leave the field untouched, the run scrapes the two example entries below (one store, one offer). Replace them with your own ids. Entries that are not found, or that are not a valid id, get a warning in the log and are skipped; the run still finishes.

Entry shapeExampleReads
store/<id>store/620ab15687bfb210e045101aevery deal the store lists now (read from the store's own deal list, bounded by Max items), or one store row when that list is empty
bare 24-character id620ab15687bfb210e045101athe same store, read without the prefix (not part of the pre-filled examples)
offer/<id>offer/6ae0cd204b311036b51df52cthat one deal (offers are short-lived, so an old id is reported as not found)

Not accepted in URL mode: web page links such as the foodhero.com homepage (it has no store or offer pages) and links to the deals feed. For a feed of deals around a place, use search mode with place names or coordinates in Locations.

National coverage, including sold-out deals:

{
"mode": "search",
"includeSoldOutOffers": true,
"maxItems": 0
}

Recurring monitoring with incremental mode:

{
"mode": "search",
"locations": ["45.5019,-73.5674"],
"incrementalMode": true,
"emitExpired": true,
"stateKey": "montreal-morning",
"maxItems": 0
}

Monitoring runs use coordinates and maxItems: 0 on purpose: a run stopped by Max items never produces EXPIRED rows, and place names carry the lookup risk described below.

Run it from your code

Python:

from apify_client import ApifyClient
client = ApifyClient("<YOUR_APIFY_TOKEN>")
run = client.actor("abotapi/foodhero-surplus-grocery-scraper").call(
run_input={"mode": "search", "locations": ["Toronto"], "maxItems": 50})
for deal in client.dataset(run["defaultDatasetId"]).iterate_items():
print(deal["productName"], deal["discountPrice"], deal["itemLeft"])

JavaScript:

import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: '<YOUR_APIFY_TOKEN>' });
const run = await client.actor('abotapi/foodhero-surplus-grocery-scraper').call({
mode: 'search', locations: ['Toronto'], maxItems: 50,
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();

Or connect it to Make, Zapier, n8n, Google Sheets or webhooks from the Integrations tab.

How Max items and Max pages shape a run

Search mode walks each area in the order given (or the covered Canadian cities, when you leave the area list empty), fully reading one area's deals feed before moving to the next, and stops the whole run once Max items is reached. Max pages per area caps how deep any single area is read (deals are read 50 at a time); leave it at 0 to read as deep as Max items allows. Raise Max items, or set it to 0, to read every deal available across every area. The deals feed itself lists at most 10 deals per store, so for every store that reports more deals than that, the actor makes one extra request to read the store's full deal list; those extra deals are returned as ordinary rows and count toward Max items, so a small Max items stops the run (and the extra requests) early. A store whose full list cannot be read still returns the deals the feed showed, the request is counted as rejected in the run summary, and the run does not produce EXPIRED rows.

Max items also limits EXPIRED rows. With incrementalMode and emitExpired on, EXPIRED rows are added at the end of a run that finished under Max items, and the total rows returned (live deals plus EXPIRED) never exceeds Max items. Deals that did not fit stay tracked and are reported as EXPIRED by a later run.

Resume and recurring updates

  • Resume (resumeFromRunId) continues one interrupted run: paste its run or dataset ID and the actor skips the deals it already saved there, so you do not pay twice.
  • Incremental mode (incrementalMode) is for repeating the same search on a schedule. Each deal is classified NEW, UPDATED (with changedFields), UNCHANGED (suppressed and not billed unless emitUnchanged is on), REAPPEARED, or EXPIRED (only with emitExpired). stateKey names or shares the stored state; leave it empty to let the actor derive one from the mode, areas, radius, store-name filter, sold-out setting and pasted ids.
  • When EXPIRED is skipped: a run stopped by Max items or Max pages, a run where a deals page could not be read, a run where a pasted entry was not a valid id, and a resumed run all skip EXPIRED detection and say so in the log.
  • Use coordinates, not place names, for incremental runs with emitExpired. Place names are looked up at the start of every run. If a lookup fails (a name that cannot be found, or the lookup not answering), that area is skipped with only a warning in the log and the run is not treated as partial. When your other areas finish normally, the run counts as complete, so every tracked deal from the skipped area is returned (and billed) as EXPIRED, then as REAPPEARED on the next run that reads that area. lat,lng coordinates are never looked up, so they avoid this. If none of the place names can be looked up, the run falls back to every FoodHero city in Canada.
  • Store details can look like a change. Turning on store details adds one lookup per store. If a store's lookup fails, its rows come back without phone and website and the run still reports success. In incremental mode those rows are then returned (and billed) as UPDATED with only phone and/or website in changedFields, and again as UPDATED on the next run when the lookup succeeds. Switching store details on or off between runs of the same state has the same effect on every row. Keep store details off for incremental monitoring, or ignore UPDATED rows whose only changed fields are phone and website.
  • Resuming into a new incremental state. Resume and Incremental mode can only be combined while that state is still empty. In that run, the deals skipped because the interrupted run already saved them are not added to the new state, so the next incremental run returns (and bills) them once more as NEW. This happens once; from then on they are tracked normally.

Send results into your apps (MCP connectors)

Optionally pipe the collected deals into the apps you already use, via Model Context Protocol (MCP) connectors. This is an extra delivery step after the collection: the Apify dataset is never changed.

What gets written to the connector: a condensed, human-readable summary of each deal, not the full JSON. Each item becomes one entry with a title and its key fields flattened to plain text. The complete record always stays in the Apify dataset.

  1. Authorize a connector once under Apify, Settings, Integrations.
  2. Select it in the "Pipe results into your apps" input field. (If the picker is empty, you have not authorized a connector yet.)
  3. For Notion, also set notionParentPageUrl to the page where deal pages should be created.

The connection is mediated by Apify's MCP proxy, so this actor never sees your third-party credentials. Leave the field empty to skip.

Input Parameters

ParameterTypeDefaultDescription
modestringsearchsearch (walk the deals feed by area) or url (read only pasted store or offer ids).
locationsarray(none: all of Canada)Search mode only. One entry per area: lat,lng coordinates, or a Canadian place name looked up for you. The form is prefilled with Montreal's coordinates; leave empty to cover every FoodHero city in Canada at once. A place name that cannot be looked up is skipped with a warning in the log.
radiusKminteger25Search mode only. Keep only stores within this distance (km) of each area.
searchStoreNamestring(none)Keep only stores whose name contains this text, matched without case. Applies in both modes.
includeSoldOutOffersbooleanfalseAlso return deals with zero portions left.
minDiscountPercentintegeremptyKeep only deals discounted by at least this percentage. Applied to the rows the run walks, so maxItems counts kept rows only.
maxPricenumberemptyKeep only deals whose current price (discountPrice, CAD) is at or below this amount.
departmentNamesarrayemptyKeep only deals whose departmentName matches one of these names, without case.
storeTypearrayemptyKeep only rows whose storeType matches one of these names, without case.
enrichStoreDetailsbooleanfalseSearch mode only. Fetch each store's detail page and fill phone, website, openingHours and pickUpInstructions on its rows (URL-mode rows already carry them). One extra request per store.
urlsarraytwo example ids (one store, one offer)URL mode only. One target per entry: a bare 24-character id is read as a store, or prefix it (store/<id> or offer/<id>) to be explicit. The form is prefilled with one example per shape.
maxItemsinteger20Stop after this many records (0 = no limit; the run then stops when the deals feed runs out for every area).
maxPagesinteger0Optional. Deals are read 50 at a time; 0 means as deep as the feed goes for each area, bounded by Max items.
resumeFromRunIdstring(none)Continue one interrupted run: paste its run or dataset ID.
incrementalModebooleanfalseReturn only new, changed and reappeared deals on recurring runs of the same selection.
stateKeystring(none)Name or share an incremental-mode state. Leave empty to derive one automatically.
emitUnchangedbooleanfalseAlso return (and bill) deals that have not changed since the last run.
ignoreFieldsForChangesarray(none)Incremental mode only: field names left out of change detection and changedFields (they stay in the output). distanceKm, openingHours and pickUpInstructions are always ignored. Changing the list never causes a wave of UPDATED rows.
emitExpiredbooleanfalseAlso return (and bill) deals seen before but no longer found. Only produced once a run has fully scanned the tracked selection, and limited by maxItems together with the live rows.
mcpConnectorsarray(none)Optional: send a summary of each deal to apps you authorized under Integrations.
notionParentPageUrlstring(none)Notion connector only: page under which deal pages are created.
maxNotifyListingsinteger50Cap on deals exported to each connector per run. Does not affect the dataset.
proxyConfigurationobjectApify ProxyConnection settings. The default Apify Proxy connection is used; the areas you search decide which deals you get back, not the connection's exit point. Free plans have no Apify Proxy, see Plan Requirement.

Output Example

Sample shape: values are illustrative placeholders, not from a live record.

{
"recordType": "offer",
"offerId": "64f1a2b3c4d5e6f7a8b9c0d1",
"url": "https://foodhero.app.link/offer/64f1a2b3c4d5e6f7a8b9c0d1",
"productName": "Frozen Berry Medley",
"productBrand": "Sample Orchards",
"productDescription": "Mixed berries, family pack",
"productSize": "600 G",
"departmentName": "Frozen",
"price": 12.99,
"discountPrice": 6.49,
"discountPercent": 50,
"currency": "CAD",
"itemLeft": 4,
"isSoldOut": false,
"isLastCall": false,
"isFrozen": true,
"isNew": true,
"co2Volume": 0.12,
"bestBefore": 1798761600,
"bestBeforeAt": "2027-01-01T00:00:00+00:00",
"storeId": "64f1a2b3c4d5e6f7a8b9c0aa",
"storeUrl": "https://foodhero.app.link/store/64f1a2b3c4d5e6f7a8b9c0aa",
"storeName": "Sample Grocery Market",
"storeType": "Grocery store",
"chainId": "64f1a2b3c4d5e6f7a8b9c0bb",
"address": "12 Sample Street, Springfield, ON, K1A0B1",
"city": "Springfield",
"state": "ON",
"postalCode": "K1A0B1",
"country": "CA",
"latitude": 45.42,
"longitude": -75.7,
"distanceKm": 2.3,
"productCount": 180,
"storeOfferCount": 42
}

Plan Requirement

FoodHero is readable through Apify's datacenter proxies, so no residential proxy is needed. Apify free plans do not include any Apify Proxy, though, so on a free plan the run cannot use the default connection:

  • If the selected connection is unavailable, the log says "The selected connection is unavailable to this run. Apify free plans do not include residential proxies." and the run continues on a backup connection ("Served over the backup connection (the default connection was unavailable)"); some pages may still be unavailable.
  • If that fails too, the run stops with "No results: residential proxies are unavailable on this run (Apify free plans do not include them), and the backup connection could not load the requested FoodHero pages", or, when the proxy refuses the account outright, "The proxy connection was refused for this account, so no page could be read. Your Apify plan does not include proxy access; a paid plan is required".
  • A paid plan, or your own proxy URLs in the proxy setting, avoids this.

FoodHero is Canada-only, so it is the areas you search, not where the connection exits, that decides which deals you get back.

FAQ

How much does it cost?

You pay per record returned (one event per deal or store row, including any UNCHANGED or EXPIRED rows you turn on), plus a small start charge per run. The Pricing tab shows the current rates. Use Max items to cap the rows in a run; it counts live deals and any EXPIRED rows together.

This actor collects only the deals and store information FoodHero's own app shows to any shopper who opens it. You are responsible for how you use the data: follow FoodHero's terms and the laws that apply to you, and get legal advice before any commercial redistribution.

Can I get only new or changed deals on a schedule?

Yes. Schedule the actor from the Schedules tab and turn on Incremental mode. From the second run on, you get back only NEW, UPDATED and REAPPEARED deals, and unchanged ones are not billed. A change to a store's opening hours or pick-up note does not by itself make a deal UPDATED (those columns, like distanceKm, are always left out of change detection).

What is the difference between Resume and Incremental mode?

Resume finishes ONE interrupted run: paste that run's ID and the deals it already saved are skipped, so you do not pay twice for the same collection. Incremental mode is for running the same search again and again (daily, say) and getting back only what changed since the previous run. Use Resume for a run that stopped partway through; use Incremental mode for recurring monitoring.

Why did my run fail instead of returning an empty dataset?

If the connection is refused before any deal could be read (for example a free plan with no proxy access, see Plan Requirement), if the pages could not be loaded at all ("Could not load any results page ... This is a connection problem, not an empty search"), or none of the pasted entries is a valid store or offer id, the run stops with a clear message so "no deals right now" is never confused with "nothing could be read". A genuinely quiet area, or a valid id that FoodHero no longer lists, still succeeds with zero records.

Why did I get EXPIRED or UPDATED rows for deals that did not change?

Two known cases in incremental mode. If a place name in locations could not be looked up, that area is skipped with only a log warning, and its tracked deals can come back as EXPIRED; use lat,lng coordinates instead. If store details are on and a store's lookup failed, its rows lose phone and website and come back as UPDATED with only those fields in changedFields. Both are described under "Resume and recurring updates" above.

Can I use it with AI agents or MCP?

Yes. Call it from any Apify integration or MCP client, and use the connector field to push results into Notion, Linear or Airtable.

🔗 Want more grocery data?

Pair this actor with these related scrapers from the same team:

🥦 ResQ Club Scraper
Scrape ResQ Club surplus food offers in Finland, Sweden and Estonia: name, price, current...
🥦 Flashfood Scraper
Scrape Flashfood discounted groceries and stores by location or store. Extract current...
🥦 Instacart Scraper
Scrape Instacart grocery catalogs with per-store prices. Run keywords across several...
🥦 E.Leclerc Scraper
Scrape E.Leclerc France (e.leclerc) grocery and retail products. Search by keyword or...
🥦 Woolworths AU Scraper
Scrape Woolworths Australia (woolworths.com.au) grocery products and reviews. Search by...
🍷 Dan Murphy’s Scraper
Scrape Dan Murphy’s products by category, keyword, or specials. Extract 45+ fields...

👉 Browse all abotapi scrapers

💬 Support

  • 🐞 Found a bug or a missing field? Open a ticket on the Issues tab. We usually reply within hours.
  • 🛠️ Need another site, extra fields or a private build? Email contact@abotapi.com.
  • ⭐ Enjoying it? A quick review on the actor page helps other users find it.