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

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 reviews field as "A maximum of 5 reviews can be returned", sorted by relevance.
  • Business Profile API: accounts.locations.reviews.list pages 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 with npm install csv for 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:

FieldWhat it holds
review_idGoogle's ID for the review.
ratingStar rating, 1 to 5.
review_textThe review text, or null when the reviewer left only a rating.
review_datetime_utc, review_timestampWhen it was posted, as ISO 8601 UTC and as Unix seconds.
owner_response_text, owner_response_datetime_utcThe owner's reply and its date, or null.
author_name, author_review_countThe reviewer's display name and how many reviews they have written.
review_link, review_photosA 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_ranking does the opposite.
  • translate_reviews=true: translates review text into the language you set. With language=en, German, Spanish, Italian, Dutch and Japanese reviews came back in English in our test.
  • region and language: which Google Maps country and language to query (defaults us and en, 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.

PlanPriceBusiness creditsBeyond thatRate limitFull pulls like ours
Free$0500 / month, hard limitNone1,000 requests / hourNone (499 reviews after the search call)
Pro$25 / month20,000 / month$0.002 each5 / second5
Ultra$75 / month100,000 / month$0.001 each8 / second26
Mega$150 / month300,000 / month$0.0005 each10 / second80
Pay As You GoNo monthly feePay per use$0.004 each5 / 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_text set to null.
  • 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.

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?

Adam Ben-Ayoun

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 LinkedIn

One 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.