{"openapi":"3.1.0","info":{"title":"Hubertino API","version":"1.0.0","summary":"Google Maps scraping API. Start a scrape, poll it, read the rows, export CSV or XLSX.","description":"The public REST API behind https://hubertino.com. It covers the same scrapes the\ndashboard runs. It is available on every plan, including the 100 free signup credits.\n\n**Authentication.** Generate a key in Dashboard → Settings. Keys start with `hub_live_`\nand are shown once. Send the key as `Authorization: Bearer hub_live_…` on every request.\n\n**Credits.** One credit is one new business delivered. When a scrape starts, Hubertino\nreserves its worst case (`categories × locations × maxResults`, capped at your balance).\nWhen it finishes, you are charged only for the new rows delivered and the rest is refunded.\nWhile a scrape is still running, `creditsCharged` is `null`.\n\n**Existing contacts.** Your account can keep a list of Google place IDs you already\nhave (Dashboard → Settings → Existing contacts; the API has no endpoint for the list).\nIt applies to every scrape you start, including through this API. A place on the list\nis still delivered with its Google Maps details, but its website is not visited (no\nemail lookup), its row carries `existing: true` and `_status: \"excluded\"`, and it costs\nno credit. Existing rows don't count toward `maxResults` or the reservation, so a\nscrape still aims for `maxResults` new places per search. A scrape lists roughly one\nexisting row per credit it reserves (about `creditsReserved`) and skips further\nmatches (counted in `progress.excludedSkipped`). `excludedCount` says how many rows\nmatched, and `searches[].excluded` how many of them each search found.\n\n**Lifecycle.** `status` moves from `queued` to `running` to `done`, or to `error`.\nThere are no webhooks. Poll `GET /scrapes/{id}` every few seconds. Rows can be read\nwhile the scrape runs: `partial` is `true` until it has finished. To stop a scrape\nearly, `POST /scrapes/{id}/cancel`: you are charged only for the new places whose\ndetails were in by then (existing contacts are never charged).\n\n**Errors.** Every error is JSON of the form `{\"error\": \"<human-readable message>\"}`\nwith a meaningful HTTP status. Validation errors name the field, for example\n`maxResults: Too big: expected number to be <=500`.\n\nThis file describes the API as implemented on 2026-09-28\n(`src/app/api/v1/**`, `src/lib/validation.ts`, `src/lib/api.ts`, `src/lib/jobs.ts`).\n","contact":{"name":"Hubertino","url":"https://hubertino.com/docs"}},"externalDocs":{"description":"Human-readable API docs","url":"https://hubertino.com/docs"},"servers":[{"url":"https://hubertino.com/api/v1","description":"Production"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Scrapes","description":"Google Maps place scrapes (and, read-only, review jobs started in the dashboard)."}],"paths":{"/scrapes":{"post":{"tags":["Scrapes"],"operationId":"createScrape","summary":"Start a scrape","description":"Starts a Google Maps scrape for every `categories × locations` pair, up to\n`maxResults` places per pair. One scrape runs at most 2,000 pairs. Split anything\nlarger into several scrapes.\n\nRate limit: 12 scrape creations per 5 minutes per account. Exceeding it returns `429`.\nThe account's email address must be verified, or the request returns `403`.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScrapeInput"},"example":{"categories":["dentist"],"locations":["Austin, TX, United States"],"maxResults":50}}}},"responses":{"201":{"description":"Scrape created. It starts as `queued`.","content":{"application/json":{"schema":{"type":"object","required":["scrape"],"properties":{"scrape":{"$ref":"#/components/schemas/Scrape"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"description":"Not enough credits to reserve the scrape (`You're out of credits — top up to start another scrape.`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The account's email address is not verified yet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded (12 scrape creations per 5 minutes per account).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"$ref":"#/components/responses/ServerError"},"503":{"description":"The scraping engine is temporarily unavailable, or it can't apply your\nexisting-contacts list right now (the scrape is refused rather than run without\nit). No credits were used. Retry in a few minutes.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"tags":["Scrapes"],"operationId":"listScrapes","summary":"List your scrapes","description":"Your 200 most recent jobs, newest first. The list also contains review jobs\n(`kind: \"reviews\"`) started in the dashboard. List items never carry live\n`progress`. Call `GET /scrapes/{id}` for that.\n","responses":{"200":{"description":"Up to 200 jobs, newest first.","content":{"application/json":{"schema":{"type":"object","required":["scrapes"],"properties":{"scrapes":{"type":"array","maxItems":200,"items":{"$ref":"#/components/schemas/Scrape"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/scrapes/{id}":{"parameters":[{"$ref":"#/components/parameters/ScrapeId"}],"get":{"tags":["Scrapes"],"operationId":"getScrape","summary":"Get one scrape with live status","description":"Returns the scrape. While it is `queued` or `running` and the engine answers, the\nresponse also carries `progress`, `coverage` and `searches` (or `reviewsProgress`\nfor a review job). The call that first sees the job finish carries them too, with\n`percent: 100`. Later calls return the stored record without them. Each call syncs\nthe job, and credits settle on the call that first sees it finish.\n","responses":{"200":{"description":"The scrape.","content":{"application/json":{"schema":{"type":"object","required":["scrape"],"properties":{"scrape":{"$ref":"#/components/schemas/Scrape"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/scrapes/{id}/cancel":{"parameters":[{"$ref":"#/components/parameters/ScrapeId"}],"post":{"tags":["Scrapes"],"operationId":"cancelScrape","summary":"Stop a scrape and settle its credits","description":"Stops the scrape and settles it at once. You are charged for every new place past\nthe detail stage by then (a `_status` of `extracted`, `enriched` or `failed`),\nincluding places whose website email hunt the cancel cuts short: results already\nserve those rows in full while the scrape runs. Rows matching your existing-contacts\nlist (`_status: \"excluded\"`) are never charged. The rest of the reservation is\nrefunded. The scrape comes back `done` with those rows, or `error` with every\nreserved credit refunded when no place had got that far, which is always the case\nfor a scrape that is still `queued`. From then on, results and the export serve\nonly the rows charged plus the scrape's existing-contact rows, which are free, so\nnothing after `error`. Existing-contact rows the engine finishes while it winds the\nscrape down are served too, still free: `excludedCount` and `resultCount` catch up\nwith them once it is done, usually within a couple of minutes, so page the results\nby their `count` and `hasMore`. There is no request body.\n\nCancelling is idempotent. A scrape that has already finished, failed or been\ncancelled comes back unchanged with `200`, and nothing is charged again. A scrape\nthat finished just before the cancel arrived is settled as finished.\n\nRate limit: 30 cancels per 5 minutes per account. Exceeding it returns `429`.\n","responses":{"200":{"description":"The settled scrape, `done` or `error`. Unchanged if it had already finished.","content":{"application/json":{"schema":{"type":"object","required":["scrape"],"properties":{"scrape":{"$ref":"#/components/schemas/Scrape"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"description":"Rate limit exceeded (30 cancels per 5 minutes per account).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"$ref":"#/components/responses/ServerError"},"503":{"description":"The scraping engine is temporarily unavailable. The scrape was not cancelled and nothing changed. Retry in a minute.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/scrapes/{id}/results":{"parameters":[{"$ref":"#/components/parameters/ScrapeId"}],"get":{"tags":["Scrapes"],"operationId":"getScrapeResults","summary":"Read result rows (paginated)","description":"Rows are readable while the scrape is still running. They stream in as they are\nfound, and `partial` says whether more are coming. Page with `offset` and `limit`\nuntil `hasMore` is `false`. `count` is the total number of rows available, not the\nsize of this page. While the scrape runs, at most the reserved number of rows is\nserved; once it has settled, at most the number charged, so none after `error`.\nWhen a job is capped, fully detailed rows come first. Rows matching your\nexisting-contacts list are free and served on top of that cap, except after `error`.\n","parameters":[{"name":"limit","in":"query","required":false,"description":"Rows per page.","schema":{"type":"integer","minimum":1,"maximum":5000,"default":1000}},{"name":"offset","in":"query","required":false,"description":"Rows to skip.","schema":{"type":"integer","minimum":0,"default":0}}],"responses":{"200":{"description":"One page of rows.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResultsPage"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"Results are not available for this job (`Results are not available for this job.`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"410":{"$ref":"#/components/responses/Gone"},"500":{"$ref":"#/components/responses/ServerError"},"503":{"$ref":"#/components/responses/Unavailable"}}}},"/scrapes/{id}/export":{"parameters":[{"$ref":"#/components/parameters/ScrapeId"}],"get":{"tags":["Scrapes"],"operationId":"exportScrape","summary":"Download the finished scrape as XLSX or CSV","description":"Returns the file itself, not JSON, as an attachment named\n`hubertino-<first 8 characters of the id>.<format>`. The export is available only\nonce the scrape is `done`. Earlier calls return `409`, as does an export of a\nscrape that found no rows. CSV columns follow the\n`columns` order of the results endpoint. The internal `_status` field is not\nexported. A scrape that ran with an existing-contacts list has one more column,\n`existing` (`True`/`False` in CSV), last.\n","parameters":[{"name":"format","in":"query","required":false,"schema":{"type":"string","enum":["xlsx","csv"],"default":"xlsx"}}],"responses":{"200":{"description":"The export file.","headers":{"Content-Disposition":{"description":"`attachment; filename=\"hubertino-<id prefix>.<format>\"`","schema":{"type":"string"}}},"content":{"text/csv":{"schema":{"type":"string"}},"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":{"schema":{"type":"string","contentMediaType":"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}}}},"400":{"description":"`format must be xlsx or csv`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"The job has not finished yet (`The job has not finished yet.`), or it found no rows (`This job found no rows, so there is nothing to export.`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"410":{"$ref":"#/components/responses/Gone"},"500":{"$ref":"#/components/responses/ServerError"},"503":{"$ref":"#/components/responses/Unavailable"}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"hub_live_…","description":"API key from Dashboard → Settings, sent as `Authorization: Bearer hub_live_…`."}},"parameters":{"ScrapeId":{"name":"id","in":"path","required":true,"description":"Scrape id, as returned by `POST /scrapes`.","schema":{"type":"string","format":"uuid"}}},"responses":{"BadRequest":{"description":"Invalid body or query parameter. The message names the field, for example\n`categories: Add at least one category.`, `limit must be <= 5000` or\n`Request body must be JSON.`\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Unauthorized":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Invalid or missing API key. Pass it as `Authorization: Bearer hub_live_…`."}}}},"NotFound":{"description":"No such scrape under your account (`Job not found.`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Gone":{"description":"The results for this job are no longer stored.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Unavailable":{"description":"Results or export temporarily unavailable. Retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"ServerError":{"description":"Unexpected error (`Something went wrong on our side. Please try again.`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"schemas":{"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"string","description":"Human-readable message. No internal details."}}},"CountryCode":{"type":"string","description":"Google country edition (`gl` code).","enum":["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"]},"ScrapeInput":{"type":"object","required":["categories","locations","maxResults"],"description":"`categories.length × locations.length` must be at most 2,000. Strings are trimmed.\nUnknown keys are ignored.\n","properties":{"categories":{"type":"array","description":"What to search for, for example `dentist` or `HVAC contractor`.","minItems":1,"maxItems":1000,"items":{"type":"string","minLength":1,"maxLength":120}},"locations":{"type":"array","description":"Cities, regions or ZIP codes, one per item, for example `Austin, TX, United States`.","minItems":1,"maxItems":10000,"items":{"type":"string","minLength":1,"maxLength":120}},"maxResults":{"type":"integer","description":"Places per category × location pair.","minimum":1,"maximum":500},"country":{"$ref":"#/components/schemas/CountryCode","description":"Google country edition. When omitted, it is inferred from the last comma\nsegment of the locations (\"…, Germany\" → `de`), falling back to `us`.\n"},"enrichWebsite":{"type":"boolean","default":true,"description":"Visit each business's website to find an email. No extra credit cost."},"enrichLinks":{"type":"boolean","default":true,"description":"Collect social profiles and ordering / reservation links."}}},"Scrape":{"type":"object","description":"A job. Fields marked \"reviews only\" appear on `kind: \"reviews\"` jobs. Those are\nstarted from the dashboard and cannot be created through this API. `progress`,\n`coverage` and `searches` (or `reviewsProgress`) appear only on\n`GET /scrapes/{id}`, while the job is active and on the call that sees it finish.\n","required":["id","kind","status","title","categories","locations","maxResults","country","enrichLinks","enrichWebsite","creditsReserved","creditsCharged","resultCount","excludedCount","exclusionsApplied","error","createdAt","finishedAt"],"properties":{"id":{"type":"string","format":"uuid"},"kind":{"type":"string","enum":["scrape","reviews"]},"status":{"type":"string","enum":["queued","running","done","error"]},"title":{"type":"string","description":"Generated title, for example `dentist — Austin, TX, United States`."},"categories":{"type":"array","items":{"type":"string"}},"locations":{"type":"array","items":{"type":"string"}},"maxResults":{"type":"integer","minimum":0,"description":"Places per pair (0 on review jobs)."},"country":{"type":"string","description":"The Google country edition used (`gl` code)."},"enrichLinks":{"type":"boolean"},"enrichWebsite":{"type":"boolean"},"placesCount":{"type":"integer","description":"Reviews only. Number of places in the job."},"maxReviews":{"type":"integer","description":"Reviews only. Reviews per place."},"sourceJobId":{"type":["string","null"],"description":"Reviews only. The scrape the review job was chained from."},"creditsReserved":{"type":"integer","description":"Credits held while the job runs. The worst case, capped at the balance."},"creditsCharged":{"type":["integer","null"],"description":"`null` until the job settles. Then it is the number of new rows delivered (on a\ncancel, the new places past the detail stage), at most `creditsReserved`. Rows\nmatching your existing-contacts list are free. 0 on `error`.\n"},"resultCount":{"type":"integer","description":"Rows found so far (the final count once `done`), existing contacts included. A\nscrape that was cancelled or stopped early settles at once, but the engine takes a\nmoment to wind it down: rows matching your existing-contacts list that it finishes\nthen are served too, free, and this count (like `excludedCount`) catches up with\nthem once it is done, usually within a couple of minutes. To read every row, page\nthe results by their `count` and `hasMore`.\n"},"excludedCount":{"type":"integer","minimum":0,"description":"Rows so far that matched your existing-contacts list. They are part of `resultCount`,\ncarry `existing: true` and are never charged. 0 when the scrape ran without a list,\nand on review jobs. After a cancel it catches up with the matches the engine settles\nwhile winding the scrape down, as `resultCount` does.\n"},"exclusionsApplied":{"type":"integer","minimum":0,"description":"Size of your existing-contacts list when the scrape started (0 = no list, and on\nreview jobs). The list is read once at the start: later edits don't affect a\nrunning scrape.\n"},"error":{"type":["string","null"],"description":"Public failure message when `status` is `error`."},"createdAt":{"type":"string","format":"date-time"},"finishedAt":{"type":["string","null"],"description":"When the job finished (ISO 8601), or `null`."},"progress":{"$ref":"#/components/schemas/Progress"},"reviewsProgress":{"$ref":"#/components/schemas/ReviewsProgress"},"coverage":{"oneOf":[{"$ref":"#/components/schemas/Coverage"},{"type":"null"}]},"searches":{"type":"array","description":"The Google Maps searches this scrape runs, with their result counts so far.","items":{"$ref":"#/components/schemas/Search"}}}},"Progress":{"type":"object","required":["stage","stageLabel","pairsDone","pairsTotal","found","extracted","excluded","updated","percent"],"properties":{"stage":{"type":"string","description":"Engine stage, for example `pending`, `discovering`, `details`, `sweep`, `emails`, `done`."},"stageLabel":{"type":"string","description":"Human label for the stage, for example `Collecting emails & socials`."},"pairsDone":{"type":"integer"},"pairsTotal":{"type":"integer"},"found":{"type":"integer","description":"Places discovered so far."},"extracted":{"type":"integer","description":"Places fully extracted, including the email attempt. Existing contacts are counted in `excluded` instead."},"excluded":{"type":"integer","description":"Places that matched your existing-contacts list. Their Maps details are in, with no\nemail lookup and no charge. A match still at `_status: \"found\"` already carries\n`existing: true` in the rows but is counted here once its details are in.\n"},"excludedSkipped":{"type":"integer","minimum":1,"description":"Further matches on your existing-contacts list that this scrape left out: it lists\nroughly one existing row per credit it reserves. Counted once per search a place\nturns up in. Present only when there were any; like the rest of `progress`, it\nappears only while the scrape is active and on the call that sees it finish.\n"},"updated":{"type":"integer","description":"Row-version counter. Re-read results when it changes."},"percent":{"type":"integer","minimum":0,"maximum":100,"description":"Held below 100 until the job is `done`."}}},"ReviewsProgress":{"type":"object","required":["stage","stageLabel","placesDone","placesTotal","reviews","updated","percent"],"properties":{"stage":{"type":"string"},"stageLabel":{"type":"string"},"placesDone":{"type":"integer"},"placesTotal":{"type":"integer"},"reviews":{"type":"integer"},"updated":{"type":"integer"},"percent":{"type":"integer","minimum":0,"maximum":100}}},"Coverage":{"type":"object","description":"How many rows carry each field. Rows matching your existing-contacts list are not counted.","required":["total"],"additionalProperties":false,"properties":{"total":{"type":"integer"},"email":{"type":"integer"},"website":{"type":"integer"},"phone":{"type":"integer"},"working_hours":{"type":"integer"},"socials":{"type":"integer"},"address":{"type":"integer"},"place_id":{"type":"integer"}}},"Search":{"type":"object","required":["query","url","results"],"properties":{"query":{"type":"string"},"url":{"type":"string","format":"uri","description":"The Google Maps search URL (always `https://www.google.com/maps/…`)."},"results":{"type":["integer","null"],"description":"New places this search found so far. Existing contacts are not counted."},"excluded":{"type":"integer","minimum":0,"description":"Rows from this search that matched your existing-contacts list, beside the new\nplaces in `results`. Present only on scrapes that ran with a list.\n"}}},"ResultsPage":{"type":"object","required":["status","partial","count","updated","coverage","columns","rows","offset","limit","hasMore"],"properties":{"status":{"type":"string","enum":["queued","running","done","error"]},"partial":{"type":"boolean","description":"`true` while the engine is still producing rows."},"count":{"type":"integer","description":"Total rows available: new rows capped at the reservation (once settled, at the\nrows charged), plus every row matching your existing-contacts list, which is never\ncapped; 0 after `error`. This is not the size of this page.\n"},"updated":{"type":"integer","description":"Row-version counter."},"coverage":{"oneOf":[{"$ref":"#/components/schemas/Coverage"},{"type":"null"}]},"columns":{"type":"array","description":"Column order for a spreadsheet: 38 place columns (39 with `existing` last on a scrape\nthat ran with an existing-contacts list), or 15 review columns on review jobs.\n","items":{"type":"string"}},"rows":{"type":"array","description":"`PlaceRow` objects for scrapes, `ReviewRow` objects for review jobs.","items":{"anyOf":[{"$ref":"#/components/schemas/PlaceRow"},{"$ref":"#/components/schemas/ReviewRow"}]}},"offset":{"type":"integer"},"limit":{"type":"integer"},"hasMore":{"type":"boolean","description":"`true` when more rows remain after this page."}}},"PlaceRow":{"type":"object","description":"One business. Every column can be `null`. The `_status` field tracks progress:\n`found` (a stub from the search list) → `extracted` (Maps details) → `enriched`\n(website email attempted), or `failed` (the place page could not be loaded). A place\non your existing-contacts list goes from `found` to `excluded` (Maps details, no email\nlookup, not charged). Rows from jobs created before the schema cleanup can carry\nlegacy keys.\n","additionalProperties":true,"properties":{"_status":{"type":"string","enum":["found","extracted","enriched","failed","excluded"]},"query":{"type":["string","null"],"description":"The Google Maps search that found the place, for example `dentist Austin, TX, United States`."},"name":{"type":["string","null"]},"category":{"type":["string","null"]},"phone":{"type":["string","null"]},"email":{"type":["string","null"],"description":"One email found on the business's own website (same-domain preferred)."},"website":{"type":["string","null"]},"domain":{"type":["string","null"]},"company_facebook":{"type":["string","null"]},"company_instagram":{"type":["string","null"]},"company_x":{"type":["string","null"]},"company_youtube":{"type":["string","null"]},"company_linkedin":{"type":["string","null"]},"address":{"type":["string","null"]},"street":{"type":["string","null"]},"city":{"type":["string","null"]},"state":{"type":["string","null"]},"state_code":{"type":["string","null"]},"postal_code":{"type":["string","null"]},"country":{"type":["string","null"]},"country_code":{"type":["string","null"]},"latitude":{"type":["number","null"]},"longitude":{"type":["number","null"]},"plus_code":{"type":["string","null"]},"rating":{"type":["number","null"]},"reviews":{"type":["integer","null"]},"business_status":{"type":["string","null"]},"price_range":{"type":["string","null"]},"service_options":{"type":["string","null"],"description":"Comma-separated."},"working_hours":{"type":["string","null"],"description":"JSON-encoded object, day → hours."},"working_hours_csv":{"type":["string","null"],"description":"`Monday,9 AM–5 PM|Tuesday,…`"},"order_links":{"type":["string","null"],"description":"Comma-separated."},"reservation_links":{"type":["string","null"],"description":"Comma-separated."},"menu_link":{"type":["string","null"]},"location_link":{"type":["string","null"],"description":"Google Maps URL of the place."},"place_id":{"type":["string","null"],"description":"Google place id (`ChIJ…`). Use it to dedupe."},"google_id":{"type":["string","null"],"description":"Google feature id (`0x…:0x…`)."},"cid":{"type":["string","null"]},"kgmid":{"type":["string","null"]},"existing":{"type":"boolean","description":"`true` when the place is on your existing-contacts list (website not visited, not\ncharged), `false` otherwise. Present only on scrapes that ran with a list\n(`exclusionsApplied > 0`), where it is also the last export column.\n"}}},"ReviewRow":{"type":"object","description":"One review, from a review job started in the dashboard.","additionalProperties":true,"properties":{"_status":{"type":"string"},"place_name":{},"place_google_id":{},"place_cid":{},"place_link":{},"review_id":{},"author":{},"author_link":{},"author_reviews":{},"author_is_local_guide":{"type":["boolean","null"]},"rating":{},"date":{},"text":{},"owner_response":{},"likes":{},"photos_count":{}}}}}}