Tutorial
October 18, 2026 · 10 min read
How to Scrape Google Maps Reviews: Get Every Review of a Business, Not Just 5
Pull every public review of any Google Maps business into a CSV, starting from about 40 lines of code. We ran it on a New York cafe with 3,731 reviews and got all of them in 38 calls and 49 seconds.
Adam Ben-Ayoun
CTO, OpenWeb Ninja
Google Maps · Reviews · Local Business Data
The Short Answer: How to Get All Reviews From Google Maps
To scrape every Google Maps review of a business, page through the Google Maps Reviews API with a cursor: up to 100 reviews per call, until the cursor comes back empty. Google's own APIs stop short of that. The Places API (New) returns at most 5 reviews per place, and the Business Profile API only lists reviews for verified locations in an account you manage.
On 19 October 2026 that returned all 3,731 reviews of Russ & Daughters Cafe in New York, the same count Google Maps shows, in 38 calls and 48.8 seconds.
- Places API (New): its Place reference describes the
reviewsfield as "A maximum of 5 reviews can be returned", sorted by relevance. - Business Profile API:
accounts.locations.reviews.listpages through up to 50 reviews at a time, but only for locations in your own Business Profile account, and Google notes it is "only valid if the specified location is verified".
What You Need
- An OpenWeb Ninja API key. The free plan includes 500 Business credits a month, no credit card: enough for the search call plus 499 reviews. Each review returned uses one credit, so a full pull of our example cafe (3,732 credits) needs the Pro plan or about $14.93 on Pay As You Go (see What It Costs).
- Node.js 18 or later and the csv package. The samples use the built-in
fetch; install the package withnpm install csvfor writing and reading the CSV. - Another language? The flow is three HTTP calls and a loop. See Using Another Language.
Every sample below ran as shown on 19 October 2026, with only the API key swapped in, and every output is what it printed.
Step 1: Find the business_id
Already have a place_id, a business_id or a Google Maps place link? Skip this step: the reviews endpoint accepts either ID, and the complete script below takes any of the three. A place link works when it contains the business ID, the 0x…:0x… part after !1s.
Otherwise, search for the business by name and city. The Local Business Data search endpoint returns the business_id that the reviews endpoint needs, plus the place_id and the review count Google Maps shows.
step1-find-business.mjs
const API_KEY = "YOUR_API_KEY"; const BASE = "https://api.openwebninja.com/local-business-data"; const res = await fetch( `${BASE}/search?` + new URLSearchParams({ query: "Russ & Daughters Cafe, New York", limit: 1 }), { headers: { "x-api-key": API_KEY } } ); const { data } = await res.json(); const { name, address, business_id, place_id, rating, review_count } = data[0]; console.log({ name, address, business_id, place_id, rating, review_count });
Output
output
{
name: 'Russ & Daughters Cafe',
address: '127 Orchard St, New York, NY 10002',
business_id: '0x89c25986daaa7ce7:0x7a4f998a3fd281a8',
place_id: 'ChIJ53yq2oZZwokRqIHSP4qZT3o',
rating: 4.6,
review_count: 3731
}A search returns the top match, and for a chain or a common name ("Starbucks, New York") that may not be the location you mean. The complete script prints the name and address and waits for a yes before it pulls anything.
Step 2: Get the First 100 Reviews
Ask /business-reviews-v2 for 100 reviews, newest first. limit accepts 1 to 100, and sort_by takes most_relevant (the default), newest, highest_ranking or lowest_ranking.
step2-first-page.mjs
const API_KEY = "YOUR_API_KEY"; const BASE = "https://api.openwebninja.com/local-business-data"; const params = new URLSearchParams({ business_id: "0x89c25986daaa7ce7:0x7a4f998a3fd281a8", // Russ & Daughters Cafe limit: 100, sort_by: "newest", }); const res = await fetch(`${BASE}/business-reviews-v2?${params}`, { headers: { "x-api-key": API_KEY }, }); const { data } = await res.json(); console.log(data.reviews.length, "reviews, next cursor:", data.cursor); console.log(JSON.stringify(data.reviews[0], null, 2));
Output, line 1
output
100 reviews, next cursor: CjEIARIpCgoAP72GEz5zw3__EhCubolT5pgbj-vNo_YAAAAAGgn92uECYTPnPDcYACIA:100
Output, the first review (trimmed)
{ "review_id": "Ci9DQUlRQUNvZENodHljRjlvT2pOSGRFeHJhREpoU1ZkV2NsODRPVFJDUVhoS2VVRRAB", "review_text": "We were told the wait would be 20–30 minutes and were then seated at the bar. ...", "rating": 1, "review_datetime_utc": "2026-10-18T16:25:45.144Z", "review_timestamp": 1792340745, "review_link": "https://www.google.com/maps/reviews/data=!4m8!14m7!1m6!2m5!1sCi9DQUlRQUN...", "review_photos": [ "https://lh3.googleusercontent.com/grass-cs/AABkmLeuN_-Rj36...", ... 2 more ], "review_language": "en", "like_count": 0, "author_name": "Mia", "author_review_count": 69, "owner_response_text": null, "owner_response_datetime_utc": null, ... 15 more fields }
Each review had 28 fields in our run. The ones most worth keeping:
| Field | What it holds |
|---|---|
review_id | Google's ID for the review. |
rating | Star rating, 1 to 5. |
review_text | The review text, or null when the reviewer left only a rating. |
review_datetime_utc, review_timestamp | When it was posted, as ISO 8601 UTC and as Unix seconds. |
owner_response_text, owner_response_datetime_utc | The owner's reply and its date, or null. |
author_name, author_review_count | The reviewer's display name and how many reviews they have written. |
review_link, review_photos | A link to the review on Google Maps, and the URLs of any photos attached. |
The cursor at the end of the response is what you send back to get the next 100.
Step 3: Page Through Every Review With the Cursor
Send the cursor back until the API returns null.
from reviews-to-csv.mjs
// Step 3: follow the cursor until there is none const reviews = []; let cursor, calls = 0; try { do { const params = { business_id: businessId, limit: 100, sort_by: "newest" }; if (cursor) params.cursor = cursor; const page = await get("/business-reviews-v2", params); reviews.push(...page.reviews); cursor = page.cursor; calls++; } while (cursor);
For Russ & Daughters Cafe this took 38 calls: 37 full pages of 100 and a last page of 31. The whole script, search call included, ran in 48.8 seconds, about 1.3 seconds per call. The oldest review it reached was from June 2014.
Step 4: Save the Reviews to CSV
Pick the columns you need and let stringify from the csv package write the file. Review text often contains line breaks and quotes, and it quotes those cells so each review stays in one row. The save runs in a finally block, so if a call fails partway, for example when a plan runs out of credits, you keep the reviews already fetched. We checked this by forcing an error after the third page: the CSV held the 300 reviews fetched up to then. The same block saves the newest review's timestamp to last-run.json for incremental runs.
from reviews-to-csv.mjs
} finally { // Step 4: save what we have, even if a call failed partway (for example, out of credits) const columns = ["review_datetime_utc", "rating", "review_text", "owner_response_text", "author_name", "review_link"]; writeFileSync("reviews.csv", stringify(reviews, { header: true, columns })); // Remember the newest review so new-reviews.mjs only fetches what comes after it const newest = reviews.find((r) => r.review_timestamp)?.review_timestamp; if (newest) writeFileSync("last-run.json", JSON.stringify({ newest })); console.log(`Saved ${reviews.length} reviews to reviews.csv in ${calls} calls`); }
Read the file back with parse, to check it
read-csv.mjs
// read-csv.mjs: load reviews.csv back and count the reviews that have text. import { readFileSync } from "node:fs"; import { parse } from "csv/sync"; const rows = parse(readFileSync("reviews.csv"), { columns: true }); const withText = rows.filter((r) => r.review_text); console.log(`${rows.length} reviews, ${withText.length} with text, ${rows.length - withText.length} rating only`); for (const r of rows.slice(0, 5)) { console.log(r.review_datetime_utc, r.rating, r.review_text.slice(0, 50)); }
Output
output
3731 reviews, 2044 with text, 1687 rating only 2026-10-18T16:25:45.144Z 1 We were told the wait would be 20–30 minutes and w 2026-10-17T18:51:04.205Z 5 2026-10-17T18:47:26.027Z 5 2026-10-17T13:24:52.872Z 5 2026-10-17T12:54:55.047Z 5 Great atmosphere and fantastic service. Friendly a
The Complete Script
All four steps in one file, 53 lines with comments. Save it as reviews-to-csv.mjs, put your key in API_KEY, and pass either the business name and city or a place_id, business_id or Google Maps place link.
reviews-to-csv.mjs
// reviews-to-csv.mjs: every Google Maps review of a business, saved to CSV. // Setup: npm install csv // Run: node reviews-to-csv.mjs "Russ & Daughters Cafe, New York" // or: node reviews-to-csv.mjs <place_id, business_id or Google Maps place link> import { writeFileSync } from "node:fs"; import { createInterface } from "node:readline/promises"; import { stringify } from "csv/sync"; const API_KEY = "YOUR_API_KEY"; const BASE = "https://api.openwebninja.com/local-business-data"; async function get(path, params) { const res = await fetch(`${BASE}${path}?${new URLSearchParams(params)}`, { headers: { "x-api-key": API_KEY }, }); if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`); return (await res.json()).data; } // Step 1: use the ID you already have, or search by name and confirm the match const input = process.argv[2]; let businessId = input.match(/0x[0-9a-f]+:0x[0-9a-f]+/i)?.[0] ?? (/^[\w-]{20,}$/.test(input) ? input : null); if (!businessId) { const [b] = await get("/search", { query: input, limit: 1 }); console.log(`${b.name}, ${b.address}: ${b.review_count} reviews`); const rl = createInterface({ input: process.stdin, output: process.stdout }); const answer = await rl.question("Pull every review of this business? (y/n) "); rl.close(); if (answer.trim().toLowerCase() !== "y") process.exit(0); businessId = b.business_id; } // Step 3: follow the cursor until there is none const reviews = []; let cursor, calls = 0; try { do { const params = { business_id: businessId, limit: 100, sort_by: "newest" }; if (cursor) params.cursor = cursor; const page = await get("/business-reviews-v2", params); reviews.push(...page.reviews); cursor = page.cursor; calls++; } while (cursor); } finally { // Step 4: save what we have, even if a call failed partway (for example, out of credits) const columns = ["review_datetime_utc", "rating", "review_text", "owner_response_text", "author_name", "review_link"]; writeFileSync("reviews.csv", stringify(reviews, { header: true, columns })); // Remember the newest review so new-reviews.mjs only fetches what comes after it const newest = reviews.find((r) => r.review_timestamp)?.review_timestamp; if (newest) writeFileSync("last-run.json", JSON.stringify({ newest })); console.log(`Saved ${reviews.length} reviews to reviews.csv in ${calls} calls`); }
Output of node reviews-to-csv.mjs "Russ & Daughters Cafe, New York"
output
Russ & Daughters Cafe, 127 Orchard St, New York, NY 10002: 3731 reviews Pull every review of this business? (y/n) y Saved 3731 reviews to reviews.csv in 38 calls
Output of node reviews-to-csv.mjs ChIJ53yq2oZZwokRqIHSP4qZT3o (a place_id, so no search and no prompt)
output
Saved 3731 reviews to reviews.csv in 38 calls
A Google Maps place link for the same cafe gave the same result.
Using Another Language
The method is the same in any language: one search call, then a loop over one endpoint. Our llms.txt for the Local Business Data API lists every endpoint and parameter, including /business-reviews-v2 and the cursor rule, in plain text a coding assistant can read. The API page also has a "Copy docs for LLM" button. A prompt like this is a good starting point:
prompt
Read the OpenWeb Ninja Local Business Data API docs at https://www.openwebninja.com/api/local-business-data/llms.txt. Then write a script in [your language] that takes a business name as an argument, finds its business_id with the /search endpoint, pages through every review of that business with /business-reviews-v2 (limit=100, sort_by=newest, follow data.cursor until it is null), and saves the date, rating, review text, owner response, author name and review link of each review to reviews.csv. Read my API key from the OPENWEBNINJA_API_KEY environment variable and send it in the x-api-key header.
Only Fetch New Reviews
For monitoring, there is no need to pull the full history on every run. Sort by newest, stop at the first review you already have, and save the newest timestamp for next time. Run reviews-to-csv.mjs once first: it writes last-run.json, so the first incremental run checks one page instead of the whole history. A common practice is a daily run after that.
new-reviews.mjs
// new-reviews.mjs: only the reviews posted since the last run. import { existsSync, readFileSync, writeFileSync } from "node:fs"; const API_KEY = "YOUR_API_KEY"; const BASE = "https://api.openwebninja.com/local-business-data"; const BUSINESS_ID = "0x89c25986daaa7ce7:0x7a4f998a3fd281a8"; // Russ & Daughters Cafe const STATE = "last-run.json"; const since = existsSync(STATE) ? JSON.parse(readFileSync(STATE, "utf8")).newest : 0; const fresh = []; let cursor, calls = 0, done = false; while (!done) { const params = { business_id: BUSINESS_ID, limit: 100, sort_by: "newest" }; if (cursor) params.cursor = cursor; const res = await fetch(`${BASE}/business-reviews-v2?${new URLSearchParams(params)}`, { headers: { "x-api-key": API_KEY }, }); const { data } = await res.json(); calls++; for (const r of data.reviews) { if (r.review_timestamp && r.review_timestamp <= since) { done = true; break; } fresh.push(r); } cursor = data.cursor; if (!cursor) done = true; } const newest = fresh.find((r) => r.review_timestamp)?.review_timestamp; if (newest) writeFileSync(STATE, JSON.stringify({ newest })); writeFileSync("new-reviews.json", JSON.stringify(fresh, null, 2)); console.log(`${fresh.length} new reviews in ${calls} call(s)`);
Output, run right after the full pull above
output
0 new reviews in 1 call(s)
A review with no timestamp is treated as new, and the saved state always comes from the newest review that has one, so a missing timestamp never resets the history. To track many businesses, keep one saved timestamp per business_id. Our reputation monitoring page shows where this fits.
Useful Options
query: only reviews that mention a word or phrase.sort_by=lowest_ranking: complaints first.highest_rankingdoes the opposite.translate_reviews=true: translates review text into thelanguageyou set. Withlanguage=en, German, Spanish, Italian, Dutch and Japanese reviews came back in English in our test.regionandlanguage: which Google Maps country and language to query (defaultsusanden, per the docs).- Many businesses: collect IDs first, for example every business of a type in a city with this area search guide or the map-grid method, then run the Step 3 loop once per
business_id.
options.mjs
const API_KEY = "YOUR_API_KEY"; const BASE = "https://api.openwebninja.com/local-business-data"; const params = new URLSearchParams({ business_id: "0x89c25986daaa7ce7:0x7a4f998a3fd281a8", // Russ & Daughters Cafe query: "wait", // only reviews that mention "wait" sort_by: "lowest_ranking", // lowest ratings first limit: 3, }); const res = await fetch(`${BASE}/business-reviews-v2?${params}`, { headers: { "x-api-key": API_KEY }, }); const { data } = await res.json(); for (const r of data.reviews) { console.log(r.rating, r.review_datetime_utc.slice(0, 10), (r.review_text ?? "").slice(0, 60)); }
Output
output
1 2019-12-25 Empty seats no line but they don't serve certain types so we 1 2019-10-08 Overrated bagels and severely overcrowded. Wasn’t worth the 1 2023-01-29 Put our name on the waitlist and the hostess said 45 min wai
Three one-star reviews that mention "wait", lowest rated first.
Errors and Retries
- Out of credits: the free plan is a hard limit of 500 Business credits a month (pricing page). The complete script saves the reviews it already has before it stops, so a pull that runs past your limit still leaves a CSV.
- 429 and 5xx: retry with backoff. The helper below waits 2, 4 and 8 seconds between attempts and fails fast on anything else.
retry.mjs
const API_KEY = "YOUR_API_KEY"; const BASE = "https://api.openwebninja.com/local-business-data"; // Retry 429 and 5xx with backoff (2 s, 4 s, 8 s). Fail fast on other errors. async function get(path, params, tries = 4) { for (let attempt = 1; ; attempt++) { const res = await fetch(`${BASE}${path}?${new URLSearchParams(params)}`, { headers: { "x-api-key": API_KEY }, }); if (res.ok) return (await res.json()).data; const retryable = res.status === 429 || res.status >= 500; if (!retryable || attempt === tries) throw new Error(`${res.status}: ${await res.text()}`); await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt)); } } const page = await get("/business-reviews-v2", { business_id: "0x89c25986daaa7ce7:0x7a4f998a3fd281a8", limit: 5 }); console.log("ok:", page.reviews.length, "reviews");
Output
output
ok: 5 reviews
What It Costs: Business Credits
Usage is counted in Business credits. Each business, review or photo the API returns uses one credit: a search that returns 20 businesses uses 20, and a reviews call that returns 100 reviews uses 100. We read our usage counter before and after the full pull. It went up by 3,732: one for the business from the search call and one for each of the 3,731 reviews.
Plans from the Google Maps Reviews API pricing table, read 19 October 2026. Last column: how many full pulls of our 3,731-review test business each plan covers.
| Plan | Price | Business credits | Beyond that | Rate limit | Full pulls like ours |
|---|---|---|---|---|---|
| Free | $0 | 500 / month, hard limit | None | 1,000 requests / hour | None (499 reviews after the search call) |
| Pro | $25 / month | 20,000 / month | $0.002 each | 5 / second | 5 |
| Ultra | $75 / month | 100,000 / month | $0.001 each | 8 / second | 26 |
| Mega | $150 / month | 300,000 / month | $0.0005 each | 10 / second | 80 |
| Pay As You Go | No monthly fee | Pay per use | $0.004 each | 5 / second | $14.93 per pull |
To watch your balance from code, see how to check your quota usage programmatically.
Limitations
- Some reviews have no text. 1,687 of the 3,731 reviews (45%) were rating-only, with
review_textset tonull. - Counts can move during a pull. A review posted while a long pull runs lands on page one after you have passed it. The next incremental run picks it up.
Sources
Read on 19 October 2026.
- Google Places API (New): Place resource reference (reviews field)
- Google Business Profile API: accounts.locations.reviews.list
- OpenWeb Ninja: Google Maps Reviews API page and pricing, Local Business Data llms.txt
Comparing providers rather than building? See Best Google Maps APIs in 2026, where we compared eight providers on search, details and reviews.
FAQ
How do I get more than 5 reviews from the Google Places API?
Can I get all Google reviews for a business I don't own?
How do I export Google Maps reviews to CSV?
How fresh is the review data?
How much does it cost to download 10,000 Google reviews?
Can I get reviews from a Google Maps link or place ID?

Written by
Adam Ben-Ayoun
CTO at OpenWeb Ninja
Adam leads engineering at OpenWeb Ninja, building the APIs and infrastructure that make public web data accessible to developers and AI agents.
Connect on LinkedInOne key. 50+ APIs. Start free.
500 free Business credits a month, no credit card. Google Maps reviews, businesses and photos, plus Google Search, Amazon and 50+ other APIs on the same key.