Reference

API

Agent-first structured business data from public Google Maps listings

One request

curl -s https://mapcensus.com/v1/scrape \
  -H 'authorization: Bearer YOUR_KEY' \
  -H 'content-type: application/json' \
  -d '{"query":"coffee shops","location":"Austin, TX","limit":10}'

Paying

Two ways. An API key spends a credit balance. Or send no key at all: the response is 402 with a machine-readable price for that request, and a client that speaks the x402 payment flow signs an authorization for it and retries. That payment buys that request and nothing more - no balance is kept for a caller with no account. It is charged the quoted price when it returns at least one row. A request that returns no rows is not charged; the payment can be presented again to retry that same request, up to 3 attempts in all, within 6 minutes of the first. It buys an answer in the same response: POST /v1/jobs needs an account, and a request that outlives its response is stopped, not charged. To pay once for many calls, add credit to an account with POST /v1/credits, which needs one.

Credits are atomic units of the settlement asset: 1,000,000 credits = $1. Billing is per record, added up from the lines that apply - at the free rate a listing row is $4.00 per thousand and a detailed place $6.00 per thousand. Three cheaper rates are opened by a monthly plan, and rows stay pay-as-you-go on every one of them - a plan buys the rate and never comes with credit. A cached answer costs 10% of a fresh one. The whole card, every unit at every rate: /pricing for a person, /v1/pricing for a program. GET /v1/account reports which rate a key is on, and when it renews.

Depth

listing reads the results feed: name, category, rating, review count. Fast and cheap, because one page load yields many rows. detail opens each place - hours, phone, website, address parts, price band, photos, booking links, the menu, the busyness curve, tickets, room rates, the owner's posts, the verdict on the area, and the places shown alongside. One page load per place, so it costs proportionally more.

Fields

GroupHolds
identityBusiness name, category list, and the listing’s stable identifiers.
locationFull address broken into parts, plus latitude, longitude and plus code.
ratingAverage rating, total review count, and the one-to-five star distribution.
contactPublic phone number and website.
hoursOpening hours per day, and whether a place has closed temporarily or for good.
busynessThe published hour-by-hour busyness curve, and how busy the place is right now.
pricePrice band and, where published, an estimated spend per person.
menuThe published menu where there is one: sections, dish names, prices and descriptions.
mediaThe listing's cover photo, and the names of the photo tabs it groups the rest under.
actionsReservation, ordering and menu links the listing offers.
reviewsIndividual reviews with rating, text, date and any per-category scores the reviewer gave, plus the aggregate topic tags.
attributesAmenities and descriptors exactly as the listing publishes them, grouped into the sections the source groups them into and each carrying whether the place offers it.
ticketsAdmission and experience tickets on sale, each with a price in USD, its seller and a booking link.
hotelFor a place that takes room bookings: star rating, the dates quoted, every rate offered, and the nearby hotels shown beside it.
postsUpdates the owner has published, with the event date and time any of them carries. What the owner says, which is not the same as what is currently true.
areaThe published verdict on the surrounding area: an overall visitor score, and the transit, sightseeing and airport scores behind it.
webPages elsewhere on the web that cite this place, each with its provider and the snippet it was shown under.
relatedThe places shown alongside this one, with rating and review count - the competitive set as the source itself draws it.
contactsThe business's own contact channels, read from its website: role email addresses (info@, sales@, bookings@...) and its Facebook, Instagram, LinkedIn, X, TikTok and YouTube profiles. Billed per place whose website was reached.

Ask for fewer and the response is smaller and faster. The default is identity, location, rating, contact, hours - every group that carries no separate charge. Naming any others adds them; "fields": "all" asks for the lot. Every field lists what is inside each group, column by column, with what it costs.

One group returns personal data. reviews carries the display name a reviewer published alongside what they wrote, so asking for it makes you a controller of that data in your own right - with your own transparency and rights obligations, separately from ours. The terms set this out and the privacy notice describes what we drop before it reaches you. If the question is about a business rather than about the people who reviewed it, rating gives you the score and the count without any of this.

Size and shape

POST /v1/scrape answers inside the request, up to 60 results. Beyond that use POST /v1/jobs, poll GET /v1/jobs/{id}, then fetch GET /v1/jobs/{id}/results. A job and its results are kept for 7 days, then deleted - and an Idempotency-Key is remembered for as long as its job is, so a key sent again after that starts a new request.

Ask for ?format=ndjson for anything large: it streams a row at a time, so neither side has to hold the whole result. csv and json are also available.

A row comes in one of two shapes. The default, ?shape=flat, is a single snake_case level - place_id, review_count - which is what a spreadsheet wants and what CSV can express. ?shape=nested returns the record as the crawler itself holds it, with the sections and spelling it uses - identifiers.placeId, rating.reviewsCount - so a pipeline already reading those records needs no translation table. It is the shape on disk, so ?format=ndjson&shape=nested&fields=all is served with no parsing at all, and it is the cheapest way to take delivery of a large result. There is no nested CSV, and asking for one is an error rather than a flat file you did not ask for.

Speed and cost

Set max_age_seconds to the oldest answer you would accept. The same question already answered inside that window returns in milliseconds from cache and is billed at the cache rate. When freshness does not matter, this is the single biggest lever on both latency and price.

The cache is keyed on what you asked about, not on how much of it you wanted: limit, fields and max_age_seconds do not split it. So a request for ten rows is served from an answer someone already crawled fifty of, at the cache rate - while a request for fifty is not served from an answer of ten, because that would be a short answer pretending to be a complete one.

Contact channels

Ask for the contacts group (at depth detail) and each place's own website is read for the channels the business publishes to be reached on: role email addresses at its own domain - info@, sales@, bookings@ - and its Facebook, Instagram, LinkedIn company, X, TikTok and YouTube profiles. An address that names a person, or a personal profile page, is never returned. The site's robots.txt is obeyed, and at most three of its pages are read. Profiles already found among the pages citing the place fill in what the site does not say.

It is billed per place whose website was read, at $3.00 per thousand at the free rate: contacts_source names the page read, and contacts_status says why a row has none - no_website, unreachable, robots or skipped, none of which is charged. These are business contact details: the terms say how they may be used, including the law that applies to the person you write to.

Webhooks

Add "webhook_url" - an https URL - to POST /v1/jobs or to a monitor, and an event is POSTed there when the job ends, whether it was crawled or answered from the cache. Events: job.completed, job.failed, job.cancelled, monitor.run, ping. The body is {"type", "timestamp", "data"}, and a finished job's data.results_url is where to fetch its rows with your key. Zapier, Make and n8n catch-hooks take it as it is.

Deliveries are signed per Standard Webhooks: webhook-id, webhook-timestamp, and webhook-signature = v1, + base64 HMAC-SHA256, keyed with the base64 after whsec_ in your secret (GET /v1/webhooks; rotate with POST /v1/webhooks/secret), over id.timestamp.body. Reject a timestamp more than a few minutes old, and dedupe on the id: a delivery that is not answered 2xx is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, with the same id. POST /v1/webhooks/test sends a ping to prove an endpoint.

// Node: verify, then trust the body
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected = 'v1,' + crypto.createHmac('sha256', key)
  .update(id + '.' + timestamp + '.' + rawBody).digest('base64');
const ok = signatureHeader.split(' ').includes(expected);

Monitors

A monitor is a saved search that runs hourly, daily, weekly and reports what changed. Each run is billed exactly as the same search asked by hand - the cache rate when a fresh answer is already held - and the report is free. A run the balance cannot cover pauses the monitor rather than failing; resume it with PATCH. Up to 50 per account.

curl -s https://mapcensus.com/v1/monitors   -H 'authorization: Bearer YOUR_KEY'   -H 'content-type: application/json'   -d '{"query":"dentists","location":"Brooklyn, NY","limit":100,"every":"daily",
       "webhook_url":"https://hooks.example.com/dentists"}'

GET /v1/monitors/{id}/changes?since= lists places new, changed (each column, before and after) or gone, oldest first. The first run is a baseline. A place is gone only after two complete runs without it, and a run that filled its whole limit calls no one gone - ranking moves, and a place past the cut has not closed. Watched columns: name, category, address, phone, website, rating, review_count, permanently_closed, temporarily_closed, hours_text, price_band, emails, social_facebook, social_instagram, social_linkedin, social_x, social_tiktok, social_youtube, where the request asks for them; "watch" narrows the list.

Spreadsheets

POST /v1/monitors/{id}/sheet returns a secret link to the monitor's latest results as CSV, and the formula to paste into Google Sheets: =IMPORTDATA("https://…"). The link needs no key - it is the key - so it is shown once, stored only as a hash, and replaced or revoked on request (DELETE on the same path).

Limits

Requests per minute (per key)120
Requests per minute (unauthenticated, per address)60
Concurrent jobs per account3
Max results per job5,000
Max reviews per place200
Monitors per account50

Errors

Every error is the same shape: a stable error code, a message for a human, and a request_id to quote. Branch on the code; the wording may change.

{
  "error": "insufficient_credits",
  "message": "…",
  "request_id": "a1b2c3d4e5"
}

Machine-readable

/openapi.json · Model Context Protocol