Google Maps scraper docs. From query to export.

From your first search to a repeatable agent workflow. Find the instructions, boundaries and API contracts here.

On this page
  1. Install the unpacked extension
  2. Start with one real task
  3. Write enrichment back to a saved list
  4. Inspect a business homepage
  5. REST API
  6. Create a bounded job
  7. Common errors
  8. Inspect an AI search answer
  9. Search an area and fetch more pages
  10. Plan research inside a polygon
  11. Export the retained page chain
  12. Analyze review themes with cited evidence
  13. Prepare and share a client report
  14. Generate a retained PDF
  15. Receive signed job receipts
  16. Send results to Google Sheets, Zapier, Make or n8n
  17. Important boundaries

Install the unpacked extension

The Google Maps scraper extension connects to your workspace API. Chrome and Edge store distribution is not approved.

  1. Download and unzip the extension package.
  2. In Chrome, open chrome://extensions. In Edge, open edge://extensions. Enable Developer mode, choose Load unpacked and select the unzipped folder.
  3. Open the extension and choose Connect with MapsScraper.dev. Sign in on the page that opens, where a workspace owner or admin approves; the extension then gets its own key for this browser session. You can instead paste a workspace API key with “Read & run bounded tasks” access from API & keys.
  4. Choose your language, use your current Maps search, set the record and unit limits, then run. The search is submitted only when its estimate fits your unit limit.
  5. Use Recover original submission when a response is lost. If the session has ended, check Task history before resubmitting.
Download unpacked extension ZIP
The OpenAPI 3.1 contract documents all implemented workspace routes, pagination, explicit budgets and response schemas. Explore the feature directory for every workspace page.

Start with one real task

  1. Create a workspace and open Explore businesses.
  2. Enter a query with its location, for example “Dentists in Austin, TX”.
  3. Choose your record limit and optional filters. Filters apply after retrieval.
  4. Review maximum unit usage. Enrichment increases the reserved ceiling.
  5. Run, inspect sources, select businesses and save to a list or export CSV.

Write enrichment back to a saved list

In Saved lists, select records that have a source ID and authorize Enrich selected with its maximum unit budget. Review batch write-back opens the new batch; pending tasks remain skipped until a later preview. You can also choose an existing details or enrichment batch from the list toolbar.

Preview the exact requested IDs and expand each before/after comparison. Fill empty fields preserves existing values; Replace selected fields copies only the nonempty source fields you choose. Review duplicate, unmatched, expired or stopped tasks in the skipped list. Confirm the displayed changed-record and skipped-task counts before applying. A changed record, source or configuration requires a fresh preview.

All eligible updates commit together. Stages, notes, tags, assignments and custom fields stay intact. Batch write-back history shows original values and per-record undo; later edits block restoration. Workspace settings control enablement and record caps. Writing back retained results uses no research units and does not call the data source. Unresolved imports still use individual Match & merge.

Agents use preview_batch_merge, apply_reviewed_batch_merge and read_batch_merge_receipt with the same review hash, scope and idempotency requirements. Never infer write authorization from source records.

Inspect a business homepage

Open Website audits, or choose Inspect this website from a saved record. Review the normalized homepage, estimate and authorize one unit. Inspect the completed HTTP/HTML observation, export it, create a report or set a recurring monitor. Bulk research also accepts one website per line.

A homepage check does not use the live Maps data source. It reads at most 256 KiB, follows up to five safe redirects and stops after 15 seconds. It does not run JavaScript or calculate a ranking score. A blocked or timed-out observation still consumes one unit.

{"kind":"website_audit","url":"https://example.com/","limit":1}

REST API

GET      /api/health
GET      /api/me
GET      /api/v1/usage
POST     /api/v1/estimate
POST     /api/v1/jobs
GET      /api/v1/jobs/{id}
GET      /api/v1/jobs/{id}/results
POST     /api/v1/jobs/{id}/cancel
GET      /api/v1/jobs/{id}/export
GET/POST /api/v1/lists
GET/POST /api/v1/lists/{id}/items
PATCH    /api/v1/lists/{id}/items/{business_id}
GET/POST /api/monitors
PATCH    /api/monitors/{id}
POST     /mcp

Create a bounded job

curl -X POST https://mapsscraper.dev/api/v1/jobs \
  -H "Authorization: Bearer $MAPSSCRAPER_API_KEY" \
  -H "Idempotency-Key: dentists-austin-tx-001" \
  -H "X-Max-Units: 1" \
  -H "Content-Type: application/json" \
  -d '{"kind":"search","query":"Dentists in Austin, TX","limit":20,"region":"us"}'

HTTP/1.1 202 Accepted
{"id":"6f1c2b3a-0000-4000-8000-000000000000","status":"queued","reserved":1}

Retry the same business operation using the same idempotency key. A changed request with that key returns 409. Jobs progress through queued, running, completed, partial, failed, cancelled or uncertain. An uncertain data source request requires reconciliation instead of an automatic paid replay.

Most tasks use one unit. Contact enrichment on a search or rank snapshot reserves one more unit per requested record and charges only for records delivered. Business details use one unit, or two with contacts; enrichment uses two. Set X-Max-Units to at least the estimate.

In Python, estimate the task, create the job with the requests library, wait for a final status and read the retained results:

import os
import time
import uuid

import requests

API = "https://mapsscraper.dev/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['MAPSSCRAPER_API_KEY']}"}
task = {"kind": "search", "query": "Dentists in Austin, TX", "limit": 20, "region": "us"}

# An estimate reserves nothing.
units = requests.post(f"{API}/estimate", json=task, headers=HEADERS, timeout=30).json()["units"]

# Keep the key: a retry of the same task must send it again.
key = str(uuid.uuid4())
job = requests.post(
    f"{API}/jobs",
    json=task,
    headers={**HEADERS, "Idempotency-Key": key, "X-Max-Units": str(units)},
    timeout=30,
)
job.raise_for_status()
job_id = job.json()["id"]

while True:
    status = requests.get(f"{API}/jobs/{job_id}", headers=HEADERS, timeout=30).json()["status"]
    if status not in ("queued", "running"):
        break
    time.sleep(5)

if status in ("completed", "partial"):
    page = requests.get(
        f"{API}/jobs/{job_id}/results",
        params={"offset": 0, "limit": 100},
        headers=HEADERS,
        timeout=30,
    ).json()
    for business in page["items"]:  # follow page["next_offset"] for more
        print(business.get("name"), business.get("phone_number"), business.get("website"))

Common errors

Every error response is JSON with a stable code, and some include details. Branch on the code; several codes can share one HTTP status.

{"error":{"code":"TASK_BUDGET_TOO_LOW","details":{"needed":11,"max_units":5}}}
HTTP statusError codeDetails
400VALIDATION_ERRORINVALID_JSONINVALID_TASKIDEMPOTENCY_KEY_REQUIREDThe body, a header or a parameter is missing or invalid. Correct the request before retrying.
400TASK_BUDGET_REQUIREDX-Max-Units is missing or is not a whole number from 1 to 10000.
401UNAUTHORIZEDSign in or use a valid key.
403INSUFFICIENT_SCOPEWORKSPACE_ACCESS_REVOKEDYour key or membership does not allow that action.
404NOT_FOUNDThe resource does not exist in this workspace.
405METHOD_NOT_ALLOWEDMCP accepts POST requests only. GET and DELETE return this code with an Allow: POST header.
409IDEMPOTENCY_CONFLICTAn idempotency key was reused for a changed request.
409JOB_NOT_CANCELLABLEThe task was already sent to the data source or has finished, so it cannot be cancelled.
details.status
410RESULT_EXPIREDThe retained result has expired. Run a new task to fetch it again.
410WORKSPACE_UNAVAILABLEThe workspace is being erased. Requests to it are refused.
422TASK_BUDGET_TOO_LOWThe estimate is higher than your X-Max-Units budget.
details.needed, details.max_units
422INSUFFICIENT_BALANCEAvailable units are below the task maximum.
details.needed, details.available
422DAILY_BUDGET_EXCEEDEDThe task would exceed the workspace daily unit limit.
details.needed, details.daily_limit, details.used_today
422PENDING_LIMIT_REACHEDToo many tasks are already queued or running.
details.pending, details.max_pending
422WORKSPACE_LIMIT_OR_BALANCE_EXCEEDEDAvailable units or a workspace task limit is insufficient.
423, 409WORKSPACE_DELETION_PENDINGThe workspace is in its deletion grace period. Changes are refused with 423, while reads, exports, account actions and read-only MCP tools keep working. A 409 with this code or WORKSPACE_NOT_ACTIVE appears only if deletion starts while a task is being admitted.
429RATE_LIMITEDA rate limit was reached. Wait the number of seconds in Retry-After, then retry.
429RESEARCH_DAILY_LIMIT_REACHEDToday's live research total is used up. Wait the number of seconds in Retry-After, until the next UTC day. Nothing is reserved.
500INTERNAL_ERRORAn unexpected error occurred. Retry later with the same idempotency key.
503PROVIDER_NOT_CONFIGUREDLive research is not open to this workspace, or the task's data source is not configured.

Inspect an AI search answer

AI search visibility retains one Google AI Overview observation: the question, country, language, optional city bias, answer text and safe citation links. Add up to five brand names and domains, review the one-unit maximum, then explicitly authorize the task. Your administrator must enable the workspace and connect a licensed source.

Inspect history or export evidence without another data source call. Compare baselines only when the query, locale and targets match. A missing overview means unknown visibility; citation order is not search rank. Incomplete evidence cannot establish a negative match. Multi-engine tracking is not available.

To follow a fixed set of questions, a verified owner or admin can schedule a recurring series: up to 10 prompts, every 24 to 720 hours, for at most 30 observations within 90 days. Review the maximum units for the whole series and confirm it explicitly. A series pauses when its authorization or configuration changes or an observation fails or is uncertain; resuming requires a fresh preview.

Open AI search visibility

Search an area and fetch more pages

In Explore, choose a text location, a point bias, a radius or a bounding box. Point bias is not a boundary. Radius and box results exclude outside or unusable coordinates and retain the exclusion counts. Workspace settings control radius and box size. Split a box that crosses the antimeridian; a bounded sample does not prove complete coverage.

Reviews and photo metadata allow up to 100 records per source page. After a result completes, open Result pages in Explore or Task history. Review the exact source options, tick the authorization checkbox and set a budget to fetch one more page. Sorting, language, translation and filters stay fixed for the chain. Missing or repeated cursors, expiry, configured limits and uncertain outcomes stop continuation.

Reading the next saved-record page and exporting an existing page do not call the data source. Fetching a new source page is a separate paid operation. For SDK and agents, read the continuation preview, then submit its exact task with a stable idempotency key and explicit budget. Do not schedule or batch cursor continuations.

Plan research inside a polygon

Choose Polygon boundary in Explore for one bounded rectangle request with exact polygon filtering. In Bulk research, choose Polygon boundary to import or paste a GeoJSON Polygon, inspect its shape, and choose rows, columns and records per cell. The schematic shows coordinates and planned cells; it is not a street map.

Positions use [longitude, latitude]. Close every ring by repeating its first position; the first ring is the outer edge and up to four additional rings are excluded holes. Self-intersections, touching or overlapping holes, and antimeridian crossings are rejected. Use separate polygons across the dateline. Workspace settings bound positions, span and planned cells.

Review cells & total budget does not call the data source. Inspect every retained cell, set the maximum units and explicitly confirm before running. All tasks reserve together or none are admitted. Receipts preserve the original plan and configuration version. A source result is filtered against both polygon and cell; shared edges can produce duplicate businesses. The requested record count is a ceiling, not a census.

Agents use preview_polygon_plan followed by create_reviewed_polygon_batch with the exact compact plan, preview hash, confirmation, stable key and aggregate budget. Use the existing batch history, cancellation and per-task result/export controls. Uncertain outcomes are never automatically rescanned.

Export the retained page chain

In Result pages, open Multi-page exports and choose Review full-chain export. Review the pages through the selected task, choose whether to keep the first record for each exact ID or keep all records, then confirm the private export. This does not fetch new data or use research units. Exports also appear in Data controls with progress, cancellation and integrity receipts.

Download JSONL, JSON, CSV or Excel after completion. Duplicate IDs with changed content are counted as conflicts; the first record is preserved. Records without IDs stay separate. Each source page has its own verified JSONL part so a failed transfer can resume by part. Excel supports up to 5 MB of stored parts; larger exports use the streaming formats. CSV retains additional columns in extra_fields_json.

Owners and admins configure daily requests, pending exports, page and source byte limits, and retention in Settings. The earliest source expiry always applies. Keep the complete marker when consuming JSON or JSONL, and verify downloaded parts against their SHA-256 receipts. A frozen chain is a retained sample, not proof of complete market coverage.

Analyze review themes with cited evidence

Start with a review statistics report in Reports. An owner or admin enables AI review analysis in Settings & usage and sets daily attempts, pending tasks, excerpt count and queue lifetime. It is off by default.

Choose Review analysis input to inspect the exact excerpts and model before authorizing one generation. Up to 40 distinct excerpts and 10 KB of excerpt text are selected in report order. A generation has its own AI analysis caps and does not use Maps wallet units. Failed or cancelled attempts still count.

Each saved theme includes checked quotations and source references. Counts are calculated from distinct cited excerpts, themes can overlap, and AI interpretations can be wrong. The result opens as a separate report: inspect it in Reports, then choose what to share or export as a PDF. Hiding the Review excerpt column also hides AI analysis.

Excerpts that contain no supported customer experience can return an evidence-insufficient receipt with no report. The attempt still counts toward limits. Inspect the input before authorizing a different analysis. A capability upgrade stops older queued approvals before inference; review a new preview. Existing reports retain their original model and prompt version.

Receipts show queued, running, completed, failed, uncertain, cancelled or expired outcomes. Cancellation prevents report creation, although a dispatched analysis still counts toward limits. Uncertain calls are never replayed automatically. API, SDK and MCP use the same preview hash, explicit confirmation and idempotency key.

Prepare and share a client report

Create a report from completed research in Reports. Edit its title, brand, section order and evidence columns, then save the presentation. Workspace Settings provides default brand colors, a bounded PNG logo, publisher website and footer. Existing saved presentations change only when you edit them or apply workspace defaults.

Share reviewed report shows the exact public projection. Unselected evidence fields and hidden sections are excluded from the stored snapshot. Source queries are private by default; methodology stays visible. Choose anyone with the link or up to 25 verified email accounts, set an expiry, and explicitly confirm the content and audience. You can also require a share password; send it separately from the link. Workspace policy can require passwords on every result/report share. No invitation or email is sent.

Copy the full link once. The secret stays in its fragment and is not retained in browser storage. A recipient with restricted access signs in with an allowed verified account, then opens the original link and enters the share password when requested. Passwords cannot be recovered; revoke and create a replacement link if one is lost. Expiry is limited by source retention. Data controls can revoke future access; deleted reports, expired sources and paused workspaces stop new reads. Downloaded copies cannot be recalled. JSON and browser Print / Save PDF use the displayed presentation. Generate saved PDF creates a private file from the saved version, with A4 or Letter paper and portrait or landscape orientation. Completed files can be attached to a matching reviewed share and inherit its access rules.

Agents use preview_report_delivery, update_report_presentation, create_reviewed_report_share, list_report_shares and revoke_report_share. Publication requires the reviewed version, snapshot hash, a stable idempotency key and explicit sharing authorization. External research text cannot grant that authorization.

Generate a retained PDF

Save the presentation, choose paper and orientation in PDF exports, then generate. The history shows queued, rendering, completed and stopped requests with download or cancel controls. Workspace Settings controls enablement, daily requests, pending requests and retention. Files expire with their sources or the configured retention period, whichever comes first. Rendering does not spend research units. Failed or uncertain renders require a new explicit request; they do not automatically retry.

Agents use generate_report_pdf with the reviewed version, snapshot hash, confirmed_render and an idempotency key. list_report_pdfs and get_report_pdf inspect receipts; cancel_report_pdf stops pending publication. Generate does not create a share or send a message. Private download and recipient attachment routes are in OpenAPI.

Receive signed job receipts

In Integrations, add a public HTTPS receiver. Save its one-time signing secret securely, enable delivery in Settings & usage, then explicitly enable the destination. Choose completed, partial, failed, uncertain or cancelled job events. Tests send only a test flag and metadata.

Each delivery carries two signature schemes. For Standard Webhooks, webhook-signature is v1, followed by the base64 HMAC-SHA256 of webhook-id, webhook-timestamp and the exact raw request body, joined by periods. The key is the base64 decoding of the text after whsec_.

The original scheme is unchanged: X-LocalOS-Signature is v1= followed by the hex HMAC-SHA256 of X-LocalOS-Timestamp, a period and the raw body, keyed with the entire whsec_ secret as text. Select the secret with X-LocalOS-Key-ID.

With either scheme, require a timestamp within five minutes and compare signatures in constant time. The event ID in webhook-id and X-LocalOS-Event-ID stays the same across retries: store it uniquely before applying effects and acknowledge duplicates with a 2xx response.

Verify a Standard Webhooks signature in Node.js:

// Verifies a webhook delivery with Web Crypto only, so it runs on
// Node.js 20+, Deno, Bun and Cloudflare Workers without dependencies.
//   const event = await verifyWebhook({ secret, headers, body });
// secret: "whsec_..." or, while rotating, ["whsec_new", "whsec_old"].
// headers: a Headers object or a plain map such as req.headers.
// body: the raw body as text or bytes, before any JSON parsing.
// Returns the parsed event. Otherwise throws an error whose code says
// why the delivery must not be trusted.
export class WebhookVerificationError extends Error {
  constructor(code) {
    super(code);
    this.name = "WebhookVerificationError";
    this.code = code;
  }
}

const encoder = new TextEncoder();

function header(headers, name) {
  if (typeof headers.get === "function") {
    return headers.get(name) ?? undefined;
  }
  for (const [key, value] of Object.entries(headers)) {
    if (key.toLowerCase() === name) {
      return Array.isArray(value) ? value[0] : value;
    }
  }
  return undefined;
}

function base64(value) {
  if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4) {
    return null;
  }
  try {
    return Uint8Array.from(atob(value), (c) => c.charCodeAt(0));
  } catch {
    return null;
  }
}

// Checks every byte, so the time taken does not reveal a mismatch.
function sameBytes(a, b) {
  if (a.length !== b.length) return false;
  let difference = 0;
  for (let i = 0; i < a.length; i++) difference |= a[i] ^ b[i];
  return difference === 0;
}

export async function verifyWebhook({
  secret,
  headers,
  body,
  toleranceSeconds = 300,
}) {
  if (!Number.isFinite(toleranceSeconds) || toleranceSeconds < 0) {
    throw new RangeError("toleranceSeconds must be 0 or more");
  }
  const fail = (code) => new WebhookVerificationError(code);
  const keys = [secret]
    .flat()
    .map((value) =>
      typeof value === "string" ? base64(value.replace(/^whsec_/, "")) : null,
    );
  if (!keys.length || keys.some((key) => !key?.length)) {
    throw fail("WEBHOOK_SECRET_INVALID");
  }
  const id = header(headers, "webhook-id");
  const timestamp = header(headers, "webhook-timestamp");
  const signatures = header(headers, "webhook-signature");
  if (!id || !timestamp || !signatures) {
    throw fail("WEBHOOK_HEADER_MISSING");
  }
  if (!/^\d{1,12}$/.test(timestamp)) {
    throw fail("WEBHOOK_TIMESTAMP_INVALID");
  }
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
    throw fail("WEBHOOK_TIMESTAMP_OUT_OF_RANGE");
  }
  const subtle = globalThis.crypto?.subtle;
  if (!subtle) throw fail("WEBHOOK_CRYPTO_UNAVAILABLE");
  const raw =
    typeof body === "string" ? encoder.encode(body) : new Uint8Array(body);
  const prefix = encoder.encode(`${id}.${timestamp}.`);
  const signed = new Uint8Array(prefix.length + raw.length);
  signed.set(prefix);
  signed.set(raw, prefix.length);
  const expected = await Promise.all(
    keys.map(async (key) => {
      const hmac = await subtle.importKey(
        "raw",
        key,
        { name: "HMAC", hash: "SHA-256" },
        false,
        ["sign"],
      );
      return new Uint8Array(await subtle.sign("HMAC", hmac, signed));
    }),
  );
  // Any listed v1 signature may match; at most 16 are checked.
  const verified = signatures
    .split(" ")
    .filter(Boolean)
    .slice(0, 16)
    .some((entry) => {
      const [version, value] = entry.split(",", 2);
      const given = version === "v1" && value ? base64(value) : null;
      return given !== null && expected.some((d) => sameBytes(given, d));
    });
  if (!verified) throw fail("WEBHOOK_SIGNATURE_INVALID");
  try {
    return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw));
  } catch {
    throw fail("WEBHOOK_PAYLOAD_INVALID");
  }
}

Delivery is at least once. Network failures, timeouts and every non-2xx answer are retried, including redirects, which are never followed. Up to eight attempts are spread over about 28 hours: after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, then 10 hours twice, or later when the receiver sends Retry-After (up to one day). A 410 Gone answer pauses the destination and cancels its pending deliveries until you resume it. Attempts and errors appear in Integrations. Pausing or removing a destination cancels pending receipts; requests already admitted may finish within ten seconds. No business records, search terms, account credentials or recipient response bodies are included in stored delivery receipts.

Send results to Google Sheets, Zapier, Make or n8n

These recipes use only the signed webhooks above and the REST API. A delivery carries the task ID, status, charged units and result count, not the result rows. Treat it as a signal: take data.job_id from it, then read the task and its rows through the API with a read-only key. Those requests are authenticated, so the rows always come from your own workspace, even if someone forges a delivery.

Create that key in API & keys with Read existing results access. It can read tasks and exports but cannot start paid research. Each recipe uses the tool's general webhook, code and HTTP request features; MapsScraper.dev has no app of its own in these tools.

Each recipe makes two GET requests with the key, sent as Authorization: Bearer <key>: first GET /api/v1/jobs/{job_id}, continuing only when its status is completed or partial, then GET /api/v1/jobs/{job_id}/export for the rows.

How delivery works

  • Answer with any 2xx status within 10 seconds and do slow work afterwards.
  • Retries reuse the same webhook-id header, so store it and skip deliveries you have already processed.
  • A failed delivery is retried, up to 8 attempts over about 28 hours, or later if your receiver sends Retry-After. Redirects are not followed.
  • A 410 Gone answer pauses the destination and cancels its pending deliveries. Fix the receiver, then turn it on again.

n8n

Use a Webhook node set to respond immediately, an HTTP Request node for each request with the key in a Header Auth credential, and an IF node between them that checks the status. This recipe does not check the signature; it relies on the authenticated requests instead.

Zapier

Zapier's Catch Raw Hook trigger passes on the unparsed body and the headers, so this recipe also checks the signature. Add a Code by Zapier step that runs JavaScript and set its Input Data: body to the raw body, id, timestamp and signature to the webhook-id, webhook-timestamp and webhook-signature headers, and secret to the signing secret. Anyone who can edit the Zap can see that secret.

const crypto = require("crypto");
const { body, id, timestamp, signature, secret } = inputData;
const fresh =
  /^\d+$/.test(timestamp || "") &&
  Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
const key = Buffer.from(
  String(secret).replace(/^whsec_/, ""),
  "base64",
);
const expected = crypto
  .createHmac("sha256", key)
  .update(`${id}.${timestamp}.${body}`)
  .digest();
const signed = String(signature || "")
  .split(" ")
  .some((entry) => {
    const [version, value = ""] = entry.split(",");
    const given = Buffer.from(value, "base64");
    return (
      version === "v1" &&
      given.length === expected.length &&
      crypto.timingSafeEqual(given, expected)
    );
  });
const valid = Boolean(id) && fresh && signed;
if (!valid) return { valid };
return { valid, job_id: JSON.parse(body).data.job_id };

Add a filter step that continues only if valid is true, then make the two requests with GET request steps. Zapier answers the delivery with 200 whether or not the check passes, so the filter, not the response, stops a delivery that fails it.

Make

Use a Custom webhook module, which turns the JSON body into fields, an HTTP module for each request, and a filter between them that checks the status. Make answers the delivery with 200 on its own. Like the n8n recipe, this one does not check the signature and relies on the authenticated requests instead.

Google Sheets

Sheets pulls the rows instead of receiving a push. In your spreadsheet, open Extensions, then Apps Script, and paste this script. Under Project Settings, add a script property named MAPSSCRAPER_API_KEY that holds a read-only API key, so the key stays out of the cells and the code.

const API = "https://mapsscraper.dev/api/v1";

function getJson(path) {
  const key = PropertiesService.getScriptProperties()
    .getProperty("MAPSSCRAPER_API_KEY");
  if (!key) throw new Error("Add the MAPSSCRAPER_API_KEY script property.");
  const response = UrlFetchApp.fetch(API + path, {
    headers: { Authorization: "Bearer " + key },
    muteHttpExceptions: true,
  });
  const status = response.getResponseCode();
  const text = response.getContentText();
  if (status !== 200) {
    throw new Error("HTTP " + status + ": " + text.slice(0, 200));
  }
  return JSON.parse(text);
}

// Text that starts with =, +, - or @ gets an apostrophe,
// so Sheets keeps it as text instead of running a formula.
function cell(value) {
  if (value === null || value === undefined) return "";
  if (typeof value === "object") value = JSON.stringify(value);
  const formula = typeof value === "string" && /^[=+\-@]/.test(value);
  return formula ? "'" + value : value;
}

function importLatestSearch() {
  const jobs = getJson("/jobs?page_size=25&kind=search").items;
  const job = jobs.find(
    (item) => item.status === "completed" || item.status === "partial",
  );
  if (!job) return;
  const path = "/jobs/" + job.id + "/export?format=json";
  const rows = getJson(path).items || [];
  if (!rows.length) return;
  const columns = [];
  rows.forEach((row) => Object.keys(row).forEach((name) => {
    if (!columns.includes(name)) columns.push(name);
  }));
  const values = [columns].concat(
    rows.map((row) => columns.map((name) => cell(row[name]))),
  );
  const book = SpreadsheetApp.getActive();
  const sheet =
    book.getSheetByName("MapsScraper") || book.insertSheet("MapsScraper");
  sheet.clearContents();
  sheet.getRange(1, 1, values.length, columns.length).setValues(values);
}

Run importLatestSearch to copy the newest finished search into a sheet named MapsScraper. To refresh it on a schedule, add a time-driven trigger for importLatestSearch under Triggers. Apps Script stops a run after 6 minutes and cannot fetch a response over 50 MB, so read larger tasks page by page from GET /api/v1/jobs/{job_id}/results.

Important boundaries

Missing website means the data source did not supply one. It does not prove a business has no website. Rank snapshots are one data source result ordering, not a full local SEO grid. Review collection is not an AI-generated sentiment report. Photos are metadata, not licensed image downloads. Recurring searches retain snapshots, compare stable identities and create workspace inbox notifications. Signed webhook receipts are opt-in and contain task metadata only; CRM-specific field synchronization remains separate.