Developers

Google Maps scraper API

The Hubertino Google Maps scraper API turns a Google Maps search into rows of business data. Send categories and locations, poll the scrape, then read JSON rows or download a CSV or XLSX file. Each business comes back in 38 columns, including an email from its own website and five social profile links, wherever it publishes them. It costs 1 credit per business delivered, from $0.276 per 1,000, and it's on every plan, including the 100 free signup credits. Two optional paid add-ons on Starter and Pro cost extra only when they deliver: Website Analysis (a label for every website, an SEO score for each real one) and Lead Enrichment (owners and top executives). See what's new.

38 columns
Email, 5 social profiles, split address, hours, Google IDs; 12 more with the paid add-ons
$0.276–$0.58
Per 1,000 businesses, by plan or pack
Pay per row delivered
Unused reserved credits are refunded automatically
OpenAPI 3.1
Import the spec into your API client or generator

Authentication

Generate a key in Settings. It starts with hub_live_ and is shown once. We only store a hash, so if you lose it you rotate it rather than recover it. Pass it as a Bearer token on every request.

curl https://hubertino.com/api/v1/scrapes \
  -H "Authorization: Bearer hub_live_your_key_here"

Base URL: https://hubertino.com/api/v1. Request and response bodies are JSON, except the export, which returns the file. A key can spend the whole account's credits (there are no read-only keys yet), so keep it server-side.

Quickstart: curl, Python, Node

Start a scrape, poll it until it's done, then download the CSV. The same three calls in each language:

export HUBERTINO_API_KEY=hub_live_your_key_here
API=https://hubertino.com/api/v1
AUTH="Authorization: Bearer $HUBERTINO_API_KEY"

# 1. Start a scrape: 1 category x 1 location, up to 50 places
#    (Starter/Pro: add "seoAudit":true and/or "findContacts":true for the paid add-ons)
ID=$(curl -sS "$API/scrapes" -H "$AUTH" -H "content-type: application/json" \
  -d '{"categories":["dentist"],"locations":["Austin, TX, United States"],"maxResults":50}' \
  | jq -r .scrape.id)

# 2. Poll until it finishes: queued -> running -> done | error
while :; do
  STATUS=$(curl -sS "$API/scrapes/$ID" -H "$AUTH" | jq -r .scrape.status)
  echo "$STATUS"
  case "$STATUS" in done) break ;; error) exit 1 ;; esac
  sleep 5
done

# 3. Download the CSV: one business per row, 38 columns (more with add-ons)
curl -sS "$API/scrapes/$ID/export?format=csv" -H "$AUTH" -o leads.csv

The full scripts add error handling, progress output and paging through the rows as JSON: scrape.sh (bash, curl, jq), scrape.py (Python 3 + requests), scrape.mjs (Node 18+).

The 38 columns

One row per business, in the same order in JSON results and in the CSV/XLSX export. Any column can be empty when the listing or website doesn't publish it. In our own samples (67,173 businesses, harvested July 2026), an email was found for 64% of the businesses where the website check ran.

GroupColumnsNotes
SearchqueryThe Google Maps search that found the place, e.g. “dentist Austin, TX, United States”.
Businessname, category, business_status, price_range, service_optionsAs the listing shows them. service_options is comma-separated.
Contactphone, email, website, domainemail is one address found on the business's own website (same-domain preferred). domain is the bare website domain, for dedupe.
Social profilescompany_facebook, company_instagram, company_x, company_youtube, company_linkedinFive profile links, from the business's own website or its Google business panel. With the decision-maker add-on, company_linkedin also comes from the company's professional profile when neither had one.
Addressaddress, street, city, state, state_code, postal_code, country, country_code, latitude, longitude, plus_codeThe full address plus its split parts, coordinates and plus code.
Reputationrating, reviewsStar rating and review count.
Hoursworking_hours, working_hours_csvA JSON object of day → hours, and the same as “Monday,9 AM–5 PM|Tuesday,…”.
Linksorder_links, reservation_links, menu_link, location_linkOrdering and booking links (comma-separated), the menu link, and the place's Google Maps URL.
Google IDsplace_id, google_id, cid, kgmidplace_id (ChIJ…) is the key to dedupe and re-match on; google_id is the feature id (0x…:0x…).

A scrape that ran with an existing-contacts list adds one more column after these: existing, true for places on your list and false for the rest in JSON, written as True/False in the CSV and as a TRUE/FALSE cell in the XLSX. Then come the columns of any paid add-on the scrape ran with. Download a sample CSV (fictional businesses, both add-ons' columns filled in, decision makers masked) to see the exact format, or read why to keep place_id and CID.

Compared with the Google Places API

Google's Places API has no email field and no social profile fields, so a lead list built on it still needs every business's website crawled afterwards. This API does that crawl as part of each row.

Hubertino APIGoogle Places API (New)
Name, category, address, coordinatesYesYes
Phone, website, rating, review count, hoursYes, at no extra costYes, as Enterprise-tier fields
Email addressYes, from the business's own website when it publishes oneNo such field
Facebook, Instagram, X, YouTube, LinkedInYes, 5 columnsNo such fields
How you pay1 credit per business delivered, from $0.276 per 1,000Per request, priced by field tier (SKU)
Built forLead lists and datasets you export and keepShowing places inside your own app
Sources

Places API fields and tiers: Google's Place Data Fields (New) page (developers.google.com/maps/…/data-fields, checked Sep 2026). More on caps, costs and terms in Google Places API vs scraping.

Credits & price per 1,000

One credit is one new business delivered (places on your existing-contacts list are free, see below); the email and social lookups cost nothing extra, and paid add-ons are extra. Per 1,000 businesses that's $0.38 on Starter ($19/month for 50,000 credits), $0.276 on Pro ($69/month for 250,000), or $0.58 / $0.472 / $0.396 with one-time packs that never expire. Plan figures assume the month's credits get used. Counting only businesses where an email was found, Pro works out at $0.434 per 1,000. See pricing or how that compares with other scrapers.

When a scrape starts we reserve its worst case (categories × locations × maxResults × (1 + the price of each add-on on), capped at your balance). When it finishes you're charged only for rows delivered and the rest is refunded. A failed scrape is refunded in full, a search that finds nothing costs nothing, and a cancelled scrape costs only the new places whose details were in by then. That's why creditsReserved can be much larger than the final creditsCharged, which stays null until the scrape settles, add-ons included (addonsPending).

Places you already have are free. Import their Google Place IDs (up to 50,000) under Dashboard → Settings → Existing contacts, and every scrape, API scrapes included, checks against that list by Place ID. A match is still delivered with its Google Maps data, but its website isn't visited (no email lookup), it costs no credit and it doesn't count toward maxResults, so each search still returns up to maxResults new places. A scrape lists roughly one match per place it reserved (placesReserved; a few more when a place's ID only shows up once its listing loads) and leaves any further ones out; while the scrape runs, progress.excludedSkipped counts them. On the scrape, exclusionsApplied is the size of the list it started with (0 without one), excludedCount how many rows matched it (each entry in searches also gives its own as excluded), and resultCount counts every row, matches included. After a cancel, both catch up with the matches the engine finishes while it winds the scrape down, usually within a couple of minutes.

Paid add-ons: Website Analysis and Lead Enrichment

Add-ons are optional extras on the Starter and Pro plans, off unless you ask for them. They cost credits on top of the 1 per place, and only for the places where they deliver something. On the Free plan, asking for one returns 403 with code: addon_plan_required (or 503 addon_unavailable for one that isn't available on any plan right now).

Add-onRequest fieldPriceCharged when
SEO audit (Website Analysis)seoAudit: true+10 creditsPer place whose own website got an SEO score. Places with no website, a social profile, a booking page or a site we couldn't reach are labelled for free.
Decision makers (Lead Enrichment)findContacts: true+20 creditsPer place with at least one named decision maker. Places we find no one for cost nothing.

Turn them on per scrape in the create request (Starter and Pro):

curl https://hubertino.com/api/v1/scrapes \
  -H "Authorization: Bearer $HUBERTINO_API_KEY" -H "content-type: application/json" \
  -d '{"categories":["dentist"],"locations":["Austin, TX, United States"],"maxResults":50,
       "seoAudit":true,"findContacts":true}'

The SEO audit checks each place's website. Every place gets a plain-English website_type, so you can spot businesses with no real website. Each own website we could check gets a seo_score from 0 to 10 and up to three seo_issues.

The decision-maker add-on finds up to two current owners, founders, CEOs or other top executives per business on public professional profiles: name, job title, LinkedIn profile and a work email, plus the company's size band. It looks a business up by its own website or LinkedIn page, so a place with neither gets none. The work email is often inferred from the company's email pattern rather than verified, so verify it before you send; it's empty when none is known. It also fills an empty company_linkedin from the company's professional profile, at no charge.

Each add-on's columns come after the 38 (and existing), in results and the export, only on a scrape that ran with it:

GroupColumnsNotes
SEO audit (Website Analysis)website_type, seo_score, seo_issueswebsite_type labels every place's website in plain English (own website, no website, a social profile, a booking or delivery page, a directory listing, unreachable…), free. seo_score (0–10) and seo_issues (up to 3, joined with “ | ”) only for an own website we could score.
Decision makers (Lead Enrichment)company_size, contact_1_name, contact_1_title, contact_1_linkedin, contact_1_email, contact_2_name, contact_2_title, contact_2_linkedin, contact_2_emailUp to two current owners, founders, CEOs or other top executives, most senior first: full name, job title, LinkedIn profile URL and a work email, empty when none is known. The work email is often inferred from the company's email pattern, not verified: verify it before you send. company_size is the company's employee band, e.g. “11-50 employees”. The add-on also fills an empty company_linkedin from the company's professional profile, free.

What you're charged: each add-on bills the rows you're served that carry its data, per row like the places themselves (a business two of your searches find is two rows, each billed). Misses cost nothing, places on your existing-contacts list never get add-ons, and a failed scrape is refunded in full. The reservation holds the worst case, every place at 1 credit plus each add-on's price, so with a small balance a scrape with add-ons stops at fewer places: read placesReserved for the place cap (without add-ons it equals creditsReserved).

The add-on charge is counted once the scrape has finished writing. Usually that's the moment it's done; after a cancel (or a stalled scrape we stop), the scrape stays addonsPending: true while it winds down, usually a minute or two, with creditsCharged still null and the export not ready yet (409), so poll until addonsPending is gone. Then seoScored is the number of rows billed for a score, contactsFound the number billed for a decision maker, and creditsBreakdown splits the charge into places, seoAudit and findContacts credits, summing to creditsCharged. While the scrape runs, progress.seoScored and progress.contactsFound are live counts, not the bill. When an add-on delivered less than it could, addonNotices.seoAudit or addonNotices.findContacts says why: unavailable (it didn't run, nothing charged), stopped (it stopped partway; you pay only for what it delivered) or partial (some website checks didn't finish before the scrape ended: those rows have no add-on data and cost nothing extra).

If an add-on isn't available, a create that asks for it returns 503 with code: addon_unavailable (retrying won't help; start without it), or addon_degraded while it's paused for a few minutes; nothing is reserved either way. Our integrations start scrapes without add-ons for now; call the API to turn one on. Snake-case fields such as seo_audit or find_contacts are ignored, like any unknown key.

Start a scrape

POST/api/v1/scrapes
FieldTypeNotes
categoriesstring[]Required. What to search for: “dentist”, “HVAC contractor”. Up to 1,000, each up to 120 characters.
locationsstring[]Required. Cities, regions or ZIP codes, one per item, e.g. “Austin, TX, United States”. Up to 10,000.
maxResultsintegerRequired. Places per category × location pair, 1–500.
countrystringOptional Google country edition, one of 32 lowercase codes: us, gb, ca, au, ie, de, fr, es, it, pt, nl, be, at, ch, se, no, dk, fi, pl, cz, lt, lv, ee, ro, hu, gr, br, mx, jp, in, nz, za. The UK is gb. When omitted, it comes from the first location that names a country (“…, Germany” → de), else us. Any other value is a 400.
enrichWebsitebooleanDefault true. Visits each business's site for an email. No extra credit cost.
enrichLinksbooleanDefault true. Collects social profiles and ordering / booking links.
seoAuditbooleanDefault false. The SEO audit add-on, Starter and Pro only: +10 credits per place whose website gets a score.
findContactsbooleanDefault false. The decision-maker add-on, Starter and Pro only: +20 credits per place with at least one decision maker.

One scrape runs up to 2,000 searches (categories × locations); split anything larger into several scrapes. Strings are trimmed and unknown keys are ignored. Returns 201 with the created scrape:

{
  "scrape": {
    "id": "6f1c…", "kind": "scrape", "status": "queued",
    "title": "dentist — Austin, TX, United States",
    "categories": ["dentist"], "locations": ["Austin, TX, United States"],
    "maxResults": 50, "country": "us", "enrichLinks": true, "enrichWebsite": true,
    "creditsReserved": 50, "creditsCharged": null, "placesReserved": 50,
    "seoAudit": false, "findContacts": false, "seoScored": null, "contactsFound": null,
    "resultCount": 0,
    "excludedCount": 0, "exclusionsApplied": 0,
    "error": null, "createdAt": "2026-09-24T09:14:03.512Z", "finishedAt": null
  }
}

Don't retry a create blindly after a timeout: there's no idempotency key yet, so a retry can start (and reserve credits for) a second scrape. List your scrapes first.

List scrapes

GET/api/v1/scrapes

Your 200 most recent jobs, newest first, as { "scrapes": [...] }. There's no paging or filtering. The list also holds review jobs started in the dashboard (kind: "reviews"). A job's status here is the last one stored; fetch the scrape itself for live status.

Get one scrape

GET/api/v1/scrapes/{id}

Returns the scrape with live status. status moves from queued to running to done, or to error (with a message in error and every reserved credit refunded). While it's active the response also carries progress, the searches it runs (once the search stage is over) and coverage (filled in when it finishes). There are no webhooks yet: poll every few seconds.

{
  "scrape": {
    "id": "6f1c…", "status": "running", "resultCount": 50,
    "creditsReserved": 50, "creditsCharged": null, "placesReserved": 50,
    "progress": {
      "stage": "emails", "stageLabel": "Collecting emails & socials",
      "pairsDone": 1, "pairsTotal": 1, "found": 50, "extracted": 31,
      "excluded": 0, "updated": 212, "percent": 74
    },
    …
  }
}

Cancel a scrape

POST/api/v1/scrapes/{id}/cancel

Stops the scrape and settles it straight away. You're charged for every new place past the detail stage by then (a _status of extracted, enriched or failed), including places whose website email hunt the cancel cuts short: results already serve those rows in full while the scrape runs. Places on your existing-contacts list (excluded) are never charged. The rest of the reservation is refunded. The scrape comes back done with those rows, or error with every credit refunded when no place had got that far, which is always the case while it's still queued. From then on, results and the export serve only the rows you were charged for plus the scrape's existing-contact rows, which are free, so nothing after error. Paid add-ons are charged for the served places they delivered on, counted once the engine has wound the scrape down (addonsPending until then). Existing contacts the engine finishes while it winds the scrape down are served too, still free: excludedCount and resultCount catch up with them within a couple of minutes, so page the results by count and hasMore. There's no request body.

curl -X POST "https://hubertino.com/api/v1/scrapes/$ID/cancel" \
  -H "Authorization: Bearer $HUBERTINO_API_KEY"

Returns 200 with the scrape, shaped like GET /scrapes/{id}. Cancelling is idempotent: a scrape that has already finished, failed or been cancelled comes back unchanged and nothing is charged again, and one that finished just before your cancel is settled as finished. If the scraping engine can't be reached you get a 503 and nothing changes, so retry.

Fetch results

GET/api/v1/scrapes/{id}/results

Paginated. limit defaults to 1,000 and caps at 5,000; offset defaults to 0. Page until hasMore is false. Rows are readable while the scrape is still running: partial is true until it has finished, and updated changes whenever rows do.

{
  "status": "done",
  "partial": false,
  "count": 4820,          // total rows available, not this page
  "updated": 9731,
  "coverage": { "total": 4820, "email": 3102, "phone": 4466, … },
  "columns": ["query", "name", "category", "phone", "email", …],
  "rows": [ { "_status": "enriched", "name": "…", "email": "…", … }, … ],
  "offset": 0,
  "limit": 1000,
  "hasMore": true
}

Every row has every column in columns, null where it has no value, plus a _status of found → extracted → enriched (or failed when the place page couldn't be loaded), so you can tell a fully detailed row from one still being filled in. A place on your existing-contacts list goes from found to excluded instead: its listing is filled in, its website isn't visited, and coverage leaves it out. While a scrape runs you're served at most the places you reserved (placesReserved), and once it has settled, only the places you were charged for, plus those free matches either way (nothing after an error). When a scrape is capped, detailed rows come first. Paging through every row, with the call() helper from the quickstart:

def rows(scrape_id, page_size=1000):
    offset = 0
    while True:
        page = call("GET", f"/scrapes/{scrape_id}/results",
                    params={"limit": page_size, "offset": offset}).json()
        yield from page["rows"]
        if not page["hasMore"] or not page["rows"]:
            return
        offset += len(page["rows"])

Export XLSX or CSV

GET/api/v1/scrapes/{id}/export?format=csv

Returns the file itself, not JSON, named hubertino-<id prefix>.<format>. format is xlsx (the default) or csv. Columns follow the results columns order, without _status. The export only exists once the scrape is done; earlier calls return 409.

curl "https://hubertino.com/api/v1/scrapes/$ID/export?format=csv" \
  -H "Authorization: Bearer $HUBERTINO_API_KEY" -o leads.csv

Limits

LimitValue
Categories per scrape1,000
Locations per scrape10,000
Searches (categories × locations) per scrape2,000
maxResults per search1–500
New scrapes12 per 5 minutes per account, dashboard included (429 beyond)
Cancels30 per 5 minutes per account (429 beyond)
Rows per results page1,000 by default, 5,000 max
Scrapes in the list200 most recent
Google country editions32

A create the API refuses (an invalid body, say) still counts toward the limit, and a 429 carries no Retry-After: wait out the 5-minute window. Reads (status, results, export) aren't rate limited, but a scrape takes minutes, so polling every few seconds is plenty. Your credit balance is the real ceiling on how much one scrape collects.

What the API doesn't do yet

  • • No webhooks. Poll GET /scrapes/{id} until the scrape finishes.
  • • No delete. You can cancel a scrape over the API, but deleting one is dashboard-only.
  • • No credit-balance endpoint. A shortfall shows up as a 402, or as a reservation below the worst case.
  • • No review scrapes. Review jobs start in the dashboard; the API can read them but not start them.
  • • No server-side filters on results. Filter rows (e.g. only those with an email) on your side.
  • • No scoped or read-only keys. An account has one key, and it can spend all the credits.
  • • No endpoint for the existing-contacts list. Import, download or clear it in Dashboard → Settings; it applies to every scrape and can't be switched off for one. Existing contacts never get add-ons.

Errors

Every error is JSON, { "error": "…" }, with a human-readable message and a meaningful status code. A refused paid add-on also carries a machine-readable code.

StatusMeaning
400Invalid body or query parameter. The message names the field, e.g. maxResults: Too big: expected number to be <=500.
401Missing or invalid API key.
402Not enough credits to start the scrape (an empty balance, not enough for one place at the add-ons' worst case, or another job reserved it first).
403The account's email address isn't verified yet (verify it, then retry), or a paid add-on was requested on the Free plan (code: addon_plan_required) or on an integration's shared account (code: addon_not_allowed).
404No such scrape under your account.
409No export yet: the scrape isn't done, is still winding down after a cancel, failed (nothing to export) or found no rows.
410The results for this scrape are no longer stored.
429More than 12 new scrapes, or 30 cancels, in 5 minutes.
500Unexpected error on our side. Retry; if it persists, contact us.
503The scraping engine, results or export are briefly unavailable. Retry in a minute. A create that fails this way uses no credits; a cancel that fails this way changes nothing. On a create, also a paid add-on you asked for that is unavailable (addon_unavailable, won't clear by retrying) or paused (addon_degraded, retry in about 15 minutes); nothing is reserved.

OpenAPI spec

The whole API as an OpenAPI 3.1 document: every endpoint, field, status and error above, plus the full row schema. Import it into an API client or generate a typed client from it. It needs no key, and it includes the paid add-on fields. With an add-on on, creditsCharged counts credits, not rows, and placesReserved is the place cap.

/api/v1/openapi.json

Changelog

Changes an API user would notice, newest first. The dashboard's API page shows the latest ones too.

  1. · OpenAPI spec 1.1.0

    • • Result rows carry _status plus every column in columns, null when empty: add-on columns used to be missing from rows that had no value for them. Internal keys, such as the listing photo the dashboard shows, are no longer sent.
    • • An unknown path under /api/v1 answers a JSON 404, and exporting a scrape that failed answers 409 “This scrape failed, so there is nothing to export.” instead of “not finished yet”.
    • • OpenAPI spec 1.1.0: request examples with the paid add-ons on, all 11 website_type labels, and corrected notes on country, stages, rate limits and coverage.
    • • Paid add-ons on the Starter and Pro plans: seoAudit (Website Analysis) labels every place's website for free and scores each own website 0–10 with up to 3 fixes, for 10 credits per site scored; findContacts (Lead Enrichment) adds up to 2 owners or top executives where the business can be matched, for 20 credits per business with one.
    • • New scrape fields placesReserved, seoAudit, findContacts, seoScored, contactsFound, addonPrices, addonsPending, creditsBreakdown and addonNotices, the add-on columns in results and exports, and a machine-readable code on add-on refusals.
    • • POST /scrapes/{id}/cancel: stop a scrape and pay only for the new places extracted so far.
    • • Your existing-contacts list applies to API scrapes: matches come back with existing: true and _status: "excluded" at no charge, counted in excludedCount, exclusionsApplied and searches[].excluded.
  2. · OpenAPI spec 1.0.0

    • • The OpenAPI 3.1 spec at /api/v1/openapi.json, for API clients and code generators.

No-code and low-code

Free n8n workflow templates and a Google Sheets script run these same calls for you.

See the integrations

Checked against the API as it runs on .

Get an API key

Start free with 100 credits, no card. The API is on every plan.

Start free with 100 credits