Developer reference
GroceryPulse API
The Canadian Grocery Price Index and the price panel underneath it, over HTTP. The aggregate index is free and needs no key — you can run a real query from this page in the next thirty seconds. The per-observation panel, the basket catalog and the shrinkflation feed are licensed.
https://grocerypulse.caJSON onlyWeekly cadencePublished from July 2026What the API returns
The CGPI is a matched-model Jevons index over a fixed 50-item basket, collected weekly from 13 cities and 22 retailer banners across roughly 160 stores. The headline is chained week over week; the nine category sub-indices are computed directly against the week of 2026-07-13 as a fixed base. Two things ship over the API: the aggregate index (national, city and banner cells, headline plus nine category sub-indices) and the per-observation price panel the index is computed from.
Three constraints to design around
- The published series starts July 2026. It is a young panel, a handful of weekly points deep. There is no year-over-year field worth reading anywhere in this API and there will not be one until enough history accumulates. Build on week-over-week and since-inception change.
- Headline levels are rebased per series; category levels share one base. Every headline (all-items) city and banner series equals 100 in its own entry week, so a Toronto headline cell at 103 and a Halifax headline cell at 101 say nothing about which city is more expensive: compare those in dollars with
basket_cost_cad. The nine category sub-indices are different: every category series equals 100 in the week of 2026-07-13, reads as change since that week, and is comparable with any other category series. - The panel is not a census. Walmart and Costco are not covered, Giant Tiger is partial, and prices are online list prices read from public retailer sites. The full matrix, including the 60% publication floor that blanks thin cells, is on the coverage page.
Vocabulary used throughout
- level
- — which aggregate a row is.
nationalrows have no city and no retailer;cityrows have a city and no retailer;bannerrows have both. - category
- — one of the nine basket categories, or
overall, the cross-category headline (stored asNULL, which is whyoverallexists as an explicit value). - series
- :
v2is the published series and the default.v1returns the frozen March–June 2026 pilot, kept only for continuity; do not splice the two together. - item_count / total_items
- — how much of the basket slice was actually priced behind that cell. Treat it as the confidence weight on the row.
Accepted values
- category
- overall, dairy, meat, bakery, produce_fruit, produce_veg, pantry, frozen, beverages, household
- city
- Vancouver, Calgary, Edmonton, Saskatoon, Winnipeg, Toronto, Ottawa, Montreal, Quebec City, Moncton, Halifax, Charlottetown, St. John's
- Display names, spelled exactly as above.
- banner
- atlanticsuperstore, dominion, farmboy, foodbasics, foodland, fortinos, freshco, gianttiger, iga, loblaws, maxi, metro, nofrills, provigo, safeway, saveonfoods, sobeys, superstore, thriftyfoods, voila, wholesaleclub, yourindependentgrocer
- Lowercase keys. Banner lineups are regional, so most keys only return rows in some cities.
Quickstart
One request, no key, no signup. This returns the full published history of the headline national index.
curl -s "https://grocerypulse.ca/api/public/index?level=national&category=overall" \ -H "Accept: application/json"
import pandas as pd
import requests
# Headline CGPI, national, all groceries. No API key required.
r = requests.get(
"https://grocerypulse.ca/api/public/index",
params={"level": "national", "category": "overall"},
timeout=60,
)
r.raise_for_status()
payload = r.json()
df = pd.DataFrame(payload["observations"])
df["date"] = pd.to_datetime(df["date"])
df = df.sort_values("date").set_index("date")
# Week-over-week is the native frequency. Nothing here supports YoY yet.
print(df[["index_value", "basket_cost_cad", "pct_change_wow"]].tail())Playground
Build a query, run it against production, and copy a client that reproduces it exactly.
Live playground
Query the free index endpoint
Every request below is a real call to the production endpoint from your browser. The published series starts in July 2026, so leaving the dates blank returns the full history rather than a rolling window.
GET https://grocerypulse.ca/api/public/index?level=national&category=overall&limit=200Not run yet.
curl -s "https://grocerypulse.ca/api/public/index?level=national&category=overall&limit=200" \ -H "Accept: application/json" # No key needed. Roughly 60 requests/min per IP. # Pipe through jq to peek at the rows: # curl -s "https://grocerypulse.ca/api/public/index?level=national&category=overall&limit=200" | jq '.observations[0:3]'
These snippets track the controls above. The free index is licensed CC BY 4.0 — cite “GroceryPulse Canadian Grocery Price Index” and link back.
Authentication
Only /api/public/index is open. Every /api/v1/* endpoint takes a bearer key issued per subscriber. Keys are stored as hashes, never in raw form, so a lost key is rotated rather than recovered.
| Parameter | Type / default | Description |
|---|---|---|
| research | lowest | Unlocks /api/v1/products. Below the tier an endpoint requires, the API answers 402 with an upgrade link rather than pretending the key is invalid. |
| standard | — | Unlocks /api/v1/index, /api/v1/prices and /api/v1/shrinkflation, on top of everything research covers. |
| professional | — | Same endpoint surface as standard, with higher per-minute and monthly limits. |
| enterprise | highest | Negotiated limits and redistribution terms. Contact sales@grocerypulse.ca. |
Each key carries its own per-minute rate limit and monthly request quota; both are visible on your account page, which is also where you rotate a key. Requests are logged per key for billing: endpoint, status, byte count and latency. Query parameters are recorded; response bodies are not.
# Keep the key in the environment, never in source control. export GP_KEY="gp_live_..." curl -s "https://grocerypulse.ca/api/v1/prices?product_id=1&city=Ottawa" \ -H "Authorization: Bearer $GP_KEY"
/api/public/index
Free · no keyThe published CGPI panel: one row per date × level × category cell. This is the same data that powers every chart on this site, and it is the endpoint the playground above calls. CORS is open, so you can call it straight from a browser or a notebook.
| Parameter | Type / default | Description |
|---|---|---|
| series | string · v2 | v2 (published) or v1 (frozen March–June 2026 pilot). Any other value falls back to v2. |
| level | string · all | national, city or banner. Omitted returns all three levels interleaved. |
| category | string · all | A category slug, or overall for the cross-category headline. Omitted returns every category including the headline. |
| city | string · all | Exact city display name, e.g. Toronto or St. John's. |
| banner | string · all | Banner key, e.g. loblaws or metro. |
| from | ISO date · 2026-07-19 | Inclusive start. Defaults to inception, so the default window is the full published history rather than a rolling one. On series=v2 an earlier date is floored to 2026-07-19. |
| to | ISO date · today | Inclusive end. |
| limit | int · 5000 (max 8000) | Page size. Values above the cap are clamped. |
| offset | int · 0 | Row offset; pair with meta.next_offset to page. |
| Field | Type | Meaning |
|---|---|---|
| date | string | The Sunday that closes the reporting week (ISO week, bucketed in America/Toronto), YYYY-MM-DD. Rows are ordered by date ascending. |
| city | string | null | null on national rows. |
| retailer | string | null | Banner key; null on national and city rows. |
| category | string | null | null is the cross-category headline (overall). |
| index_value | number | Index level. Every row, headline and category alike, is direct fixed-base: 100 in the week of 2026-07-13, and comparable with every other series. |
| basket_cost_cad | number | null | Cost of the basket slice in dollars. This is the metric to use for cross-city and cross-banner comparison. |
| pct_change_wow | number | null | Week-over-week percent change. On headline rows it is same-composition by construction; on category rows it is the ratio of the two published levels. null in a series' first week and, for categories, after a missing week. |
| pct_change_mom | number | null | Percent change in the index level against the same series 28 days (four weeks) earlier. null whenever that week is absent, which is common on a series this young. |
| item_count | int | null | Items actually priced in the link behind this cell. |
| total_items | int | null | Items the cell would contain at full coverage. |
A cell only publishes when at least 60% of its basket slice is priced in both weeks being linked, so a missing row is usually a suppressed thin cell rather than a gap in collection. Rows are ordered by date ascending.
{
"meta": {
"source": "GroceryPulse CGPI",
"series": "v2",
"endpoint": "public/index",
"license": "CC BY 4.0 — attribution to GroceryPulse required. ...",
"note": "Aggregate index only. Per-item price microdata is ...",
"filters": {
"series": "v2", "level": "national", "category": "overall",
"city": "all", "banner": "all",
"from": "2026-07-19", "to": "<YYYY-MM-DD>"
},
"limit": 5000,
"offset": 0,
"count": <int>,
"has_more": false,
"next_offset": null,
"generated_at": "<ISO-8601>"
},
"observations": [
{
"date": "<YYYY-MM-DD>",
"city": null,
"retailer": null,
"category": null,
"index_value": <number>,
"basket_cost_cad": <number|null>,
"pct_change_wow": <number|null>,
"pct_change_mom": <number|null>,
"item_count": <int|null>,
"total_items": <int|null>
}
]
}Placeholders in angle brackets show types, not values. Run the playground for real numbers.
/api/v1/index
API key requiredminimum tierstandardThe same index panel as the public endpoint, with a larger page cap, the full methodology string, and a per-week geographic coverage block so an incomplete week is obvious rather than silent.
| Parameter | Type / default | Description |
|---|---|---|
| series | string · v2 | v2 (published) or v1 (frozen pilot). |
| level | string · all | national, city or banner. |
| category | string · all | Category slug, or overall for the headline. |
| city | string · all | Exact city display name. |
| banner | string · all | Banner key. |
| from | ISO date · 90 days ago | Note the different default from the public endpoint: this one is a rolling 90-day window. On series=v2 it is floored to 2026-07-19. |
| to | ISO date · today | Inclusive end. |
| limit | int · 5000 (max 10000) | Page size. |
| offset | int · 0 | Row offset. |
Two fields worth reading carefully
meta.coverage reports which cities were present in the most recent week of your window and which were not. A national aggregate built from nine of 13 cities is still a valid number, but you should know before you chart it.
pct_change_yoy is present for schema stability and is null today: it compares the level to the same series 364 days earlier, and with the published series starting July 2026 no such week exists yet. Expect it to stay null well into 2027. Do not manufacture one by splicing the v1 pilot onto the published series — the two are computed on different bases.
{
"meta": {
"source": "GroceryPulse CGPI",
"version": "v1",
"series": "v2",
"endpoint": "index",
"methodology": "Direct fixed-base matched-model Jevons vs the week of 2026-07-13, one estimator for the headline and the nine category sub-indices, with the top and bottom 10 percent of store-level relatives trimmed per product",
"coverage": {
"as_of": "<YYYY-MM-DD>",
"cities_included": ["<city>", "..."],
"cities_missing": ["<city>", "..."],
"cities_total": 13
},
"filters": { "...": "..." },
"limit": 5000, "offset": 0, "count": <int>,
"has_more": false, "next_offset": null,
"generated_at": "<ISO-8601>"
},
"observations": [ /* as public/index, plus pct_change_yoy */ ]
}/api/v1/prices
API key requiredminimum tierstandardThe microdata: one row per product, per store, per collection, with the canonical basket linkage attached. This is the licensed panel — everything the index is built from, before aggregation.
| Parameter | Type / default | Description |
|---|---|---|
| product_id | int · all | Canonical basket product id (1–50). Get the catalog from /api/v1/products. |
| city | string · all | Exact city display name; filters on the store's city. |
| banner | string · all | Banner key. |
| from | ISO date · 30 days ago | Inclusive start on observed_at. |
| to | ISO date · today | Inclusive end — the whole day is included, sub-second rows and all. |
| limit | int · 5000 (max 10000) | Page size. |
| offset | int · 0 | Row offset. |
What the endpoint quietly does for you
- Trust
effective_price. It is the price the index uses.regular_priceandsale_priceare advisory: where a legacy row violates the basis invariant (a regular price below the effective price, or more than four times it) the regular price is restated on read and the false sale flag is dropped. - Dead rows are excluded. Observations from deactivated stores, retired product mappings and swapped-out canonical products never appear, even though the underlying table is append-only.
- The index's quality gate applies here too. Every row passes the same gate as the published index and the licensed CSV exports: the scraped title and package size are checked against the canonical product's spec, and mappings verified as wrong-product or wrong-basis are quarantined (banner-wide or per store). So the panel never contains an observation the index rejected.
meta.filtered_identityandmeta.filtered_quarantinereport what was dropped from the page, andmeta.gate_versiondates the gate revision. Paging walks the raw sequence, so filtering only shrinkscount, never shifts a page boundary. - Text is repaired on read. Double-UTF-8 mojibake in retailer names and brands is corrected in the response, which matters for French-language banner listings.
- Corrections propagate backwards. Because observations are append-only and the index is recomputed at read time, a fixed mapping restates history rather than leaving a step in the series.
{
"meta": {
"endpoint": "prices", "count": <int>, "has_more": false,
"filtered_identity": <int>, "filtered_quarantine": <int>,
"gate_version": "gate-2", "...": "..."
},
"observations": [
{
"id": <int>,
"retailer_product_id": <int>,
"store_id": <int>,
"regular_price": <number|null>,
"sale_price": <number|null>,
"effective_price": <number>,
"unit_price": <number|null>,
"package_size": "<string|null>",
"in_stock": <bool|null>,
"is_on_sale": <bool>,
"scraped_name": "<string|null>",
"observed_at": "<ISO-8601>",
"retailer_products": {
"product_id": <int>,
"retailer": "<string>",
"banner": "<string>",
"retailer_sku": "<string|null>",
"retailer_name": "<string>",
"brand": "<string|null>",
"is_store_brand": <bool|null>,
"products": {
"id": <int>, "name": "<string>", "category": "<string>",
"subcategory": "<string|null>", "basket_weight": <number>
}
},
"stores": { "city": "<string>", "province": "<string>" }
}
]
}/api/v1/products
API key requiredminimum tierresearchThe canonical basket: the fixed list of products the index prices every week, with each item's standardized spec and its weight in the basket. Small, fixed, and unpaginated — fetch it once and cache it.
| Parameter | Type / default | Description |
|---|---|---|
| (none) | — | Returns the full active catalog, ordered by category then name. There is no pagination and no date filter. |
| Field | Type | Meaning |
|---|---|---|
| id | int | Canonical product id — the value to pass as product_id. |
| name | string | Canonical product name, banner-independent. |
| category | string | One of the nine basket categories. |
| subcategory | string | null | Finer grouping within the category. |
| standard_size | number | Standardized pack size the item is normalized to before comparison. |
| unit | string | g, mL, or unit. |
| basket_weight | number | The item's relative importance in the basket. Raw weights sum to 0.96 across the catalog, not 1 — divide by the total for a true share. The index normalizes internally. |
{
"meta": {
"source": "GroceryPulse CGPI",
"version": "v1",
"endpoint": "products",
"count": <int>,
"generated_at": "<ISO-8601>"
},
"products": [
{
"id": <int>,
"name": "<string>",
"category": "<string>",
"subcategory": "<string|null>",
"standard_size": <number>,
"unit": "g" | "mL" | "unit",
"basket_weight": <number>
}
]
}/api/v1/shrinkflation
API key requiredminimum tierstandardDetected package-size reductions, each graded by how cleanly the unit-price rise mirrors the size cut. Useful as a hidden-price-increase signal that never shows up in a shelf price.
| Parameter | Type / default | Description |
|---|---|---|
| confidence | string · high | high, medium or low. Cumulative: medium returns high and medium; low returns everything. |
| banner | string · all | Banner key. |
| from | ISO date · 90 days ago | Inclusive start on the detection date. |
| to | ISO date · today | Inclusive end, whole day included. |
| limit | int · 1000 (max 10000) | Page size. |
| offset | int · 0 | Row offset. |
How the grade is assigned
When a pack shrinks by X%, an unchanged shelf price should push the unit price up by about X%. Events where the two agree within 15 points grade high; within 30 points, medium. Everything else grades low and carries a flag saying why: a size move under 2% is noise (suspect_tiny), an exact −20% sits on the detector's clamp and usually means a SKU swap (suspect_swap), and a unit price that disagrees or is missing is suspect_unit_mismatch. Only high belongs in a published claim without a human look.
The grade is derived per request rather than stored, so paging happens after filtering: meta.total_matched is the size of the filtered set and meta.count is the page. If a very wide window trips the internal scan cap, the response says so with meta.scan_truncated — narrow the dates rather than trusting the total.
{
"meta": {
"endpoint": "shrinkflation",
"filters": { "banner": "all", "confidence": "high", "...": "..." },
"limit": 1000, "offset": 0,
"count": <int>,
"total_matched": <int>,
"has_more": false, "next_offset": null,
"generated_at": "<ISO-8601>"
},
"events": [
{
"id": <int>,
"old_package_size": "<string>", "new_package_size": "<string>",
"old_size_numeric": <number|null>, "new_size_numeric": <number|null>,
"size_unit": "<string|null>",
"pct_size_change": <number|null>,
"old_unit_price": <number|null>, "new_unit_price": <number|null>,
"pct_unit_price_change": <number|null>,
"shelf_price_at_change": <number|null>,
"detected_at": "<ISO-8601>",
"confidence": "high" | "medium" | "low",
"flag": "ok" | "suspect_unit_mismatch" | "suspect_swap" | "suspect_tiny",
"retailer_products": {
"retailer": "<string>", "banner": "<string>",
"retailer_name": "<string>", "brand": "<string|null>",
"products": { "id": <int>, "name": "<string>", "category": "<string>" }
}
}
]
}Pagination
Every paged endpoint answers with meta.has_more and meta.next_offset. Loop until has_more is false. Rows are returned in a total order (date then id), so consecutive pages never skip or duplicate a row.
Page sizes are clamped server-side: 8,000 rows on the public index, 10,000 on the licensed endpoints, 1,000 by default on shrinkflation. Asking for more returns the cap, not an error.
The full published index is small enough that most users never page at all — the national headline series is one row per week. Paging matters at banner level, where the panel is 13 cities × 22 banners × ten series per week.
import requests
BASE = "https://grocerypulse.ca/api/public/index"
params = {"level": "banner", "category": "overall", "limit": 5000, "offset": 0}
rows = []
while True:
payload = requests.get(BASE, params=params, timeout=60).json()
rows += payload["observations"]
if not payload["meta"]["has_more"]:
break
params["offset"] = payload["meta"]["next_offset"]
print(len(rows), "observations")Rate limits and errors
Errors are JSON with an error string. Nothing returns HTML.
Public endpoint
Roughly 60 requests per minute per IP, as a token bucket: a burst of 60 then about one request per second sustained. Over the limit returns 429 with a Retry-After header. Responses are cacheable — cache them rather than re-requesting the same window in a loop.
Licensed endpoints
Limits are per key: a per-minute rate limit and a monthly request quota, both set on the key itself and shown on your account page. Either one exceeded returns 429. If your workload needs more throughput, that is a conversation, not a hard wall — write to sales@grocerypulse.ca.
| Status | Meaning |
|---|---|
| 400 | A date parameter could not be parsed. Dates are ISO YYYY-MM-DD. |
| 401 | Missing or invalid bearer token (licensed endpoints only). |
| 402 | The key is on a lower tier than the endpoint requires; the body carries an upgrade link. |
| 403 | The key is disabled or has expired. |
| 429 | Per-minute rate limit or monthly quota exceeded. |
| 500 | Upstream query failure. Retry with backoff before reporting it. |
Licensing and attribution
Two different licences, because the aggregate index and the underlying panel are two different products.
Aggregate index
CC BY 4.0Chart it, quote it, publish it, model on it — commercially included — as long as you attribute GroceryPulse and link back. What CC BY does not cover is bulk redistribution or resale of the series as a dataset; that needs a licence.
Per-item panel
CommercialThe observation-level data behind /api/v1/prices, the basket catalog and the shrinkflation feed are licensed per subscriber, for internal use, under the terms of your subscription. Onward distribution is negotiated separately.
Source: GroceryPulse Canadian Grocery Price Index (CGPI), grocerypulse.ca. Licensed CC BY 4.0.
Recipes
Two things people actually build with this, written the honest way.
Aggregating to a monthly series
If you need a monthly frequency, aggregate the weeks yourself rather than assuming one. The discipline is in what you claim afterwards: this series measures advertised shelf prices, whereas official food statistics measure transaction prices at the till, so the two are not the same quantity and any mapping between them has to be established empirically rather than assumed. The panel also excludes Walmart and Costco, so the channel mix differs by construction. With the series starting July 2026, treat any such mapping as provisional and say so in your note.
import pandas as pd
import requests
# 1. Pull the headline national CGPI (free, no key).
obs = requests.get(
"https://grocerypulse.ca/api/public/index",
params={"level": "national", "category": "overall"},
timeout=60,
).json()["observations"]
cgpi = pd.DataFrame(obs)
cgpi["date"] = pd.to_datetime(cgpi["date"])
cgpi = cgpi.set_index("date").sort_index()
# 2. Weekly -> monthly. Take the mean of the weeks inside each month, then the
# month-over-month change. There is no YoY comparison to make yet: the
# published series starts July 2026.
monthly = cgpi["index_value"].resample("MS").mean()
mom = monthly.pct_change() * 100
# 3. NOTE: this series measures ADVERTISED shelf prices. Official food price
# statistics measure TRANSACTION prices at the till. They are different
# quantities. If you relate them, fit the mapping on overlapping months
# empirically rather than assuming pass-through, and remember the panel
# excludes Walmart and Costco.
print(mom.dropna())Equity research on the grocers
Banner-level cells let you watch relative price positioning between Loblaw, Empire and Metro banners in the same city, week by week — the discount versus conventional spread within a parent company included. Use basket_cost_cad for level comparisons across banners and pct_change_wow for moves; never compare raw headline index levels across two banners, because each headline series is 100 in the week of 2026-07-13 (every series shares that base week and are comparable). Pair it with /api/v1/shrinkflation to catch price increases taken through pack size rather than the shelf tag.
import pandas as pd
import requests
# Relative price positioning across banners in one city.
# Compare basket_cost_cad in DOLLARS, not headline index levels: each
# headline (overall) series is rebased to 100 in its own entry week, so
# headline levels are not comparable across banners. (Category series
# share the week of 2026-07-13 as base and are comparable.)
obs = requests.get(
"https://grocerypulse.ca/api/public/index",
params={"level": "banner", "category": "overall", "city": "Toronto"},
timeout=60,
).json()["observations"]
df = pd.DataFrame(obs)
df["date"] = pd.to_datetime(df["date"])
spread = df.pivot_table(index="date", columns="retailer", values="basket_cost_cad")
print(spread.tail())
# Same-composition weekly moves per banner (index-based, within series):
moves = df.pivot_table(index="date", columns="retailer", values="pct_change_wow")
print(moves.tail())OpenAPI spec and support
A machine-readable OpenAPI 3.0.3 document describes every endpoint, free and licensed. Point Postman, Insomnia or a client generator at it.
The spec covers the four licensed /api/v1 endpoints plus the free /api/public/index, which is declared without a security requirement so an imported collection runs it straight away with no key. Evaluating the data and want a specific cut of it to test against? Write to sales@grocerypulse.ca.