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
| Group | Holds |
|---|---|
identity | Business name, category list, and the listing’s stable identifiers. |
location | Full address broken into parts, plus latitude, longitude and plus code. |
rating | Average rating, total review count, and the one-to-five star distribution. |
contact | Public phone number and website. |
hours | Opening hours per day, and whether a place has closed temporarily or for good. |
busyness | The published hour-by-hour busyness curve, and how busy the place is right now. |
price | Price band and, where published, an estimated spend per person. |
menu | The published menu where there is one: sections, dish names, prices and descriptions. |
media | The listing's cover photo, and the names of the photo tabs it groups the rest under. |
actions | Reservation, ordering and menu links the listing offers. |
reviews | Individual reviews with rating, text, date and any per-category scores the reviewer gave, plus the aggregate topic tags. |
attributes | Amenities 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. |
tickets | Admission and experience tickets on sale, each with a price in USD, its seller and a booking link. |
hotel | For a place that takes room bookings: star rating, the dates quoted, every rate offered, and the nearby hotels shown beside it. |
posts | Updates 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. |
area | The published verdict on the surrounding area: an overall visitor score, and the transit, sightseeing and airport scores behind it. |
web | Pages elsewhere on the web that cite this place, each with its provider and the snippet it was shown under. |
related | The places shown alongside this one, with rating and review count - the competitive set as the source itself draws it. |
contacts | The 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 account | 3 |
| Max results per job | 5,000 |
| Max reviews per place | 200 |
| Monitors per account | 50 |
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"
}