Read a Location's Google Photos from Stored Data
List the Google Business Profile photos Synup has stored for a location, owner and customer, with freshness and paging, without calling Google.
At a glance
- What this guide accomplishes
- List a location's stored owner and customer Google photos, page through them, and read how fresh the data is.
- APIs used
- get
/api/v1/locations/{id}/media/googleList a location's stored Google photos - get
/api/v1/media/googleList stored Google photos - get
/api/v1/mediaGet location media - post
/api/v1/locations/{id}/mediaUpload photos to a location
- get
- Reference
- MediaLocations
- Prerequisites
- An API key with the media:read scope
- A location id (loc_...), from the locations endpoints
- Typical use cases
- Showing a location's Google photos in your own dashboard
- Checking which photos customers added to a listing
- Telling a stale photo list from a complete one
Google photos reach you in two ways, and they are not the same data. GET /api/v1/media returns Synup's own media library for a location, grouped by category. The two endpoints in this guide, GET /api/v1/locations/{id}/media/google and GET /api/v1/media/google, return the photos Synup has stored from Google itself: the ones the business uploaded (owner) and the ones customers added (ugc).
Both reads are stored-only. They never call Google and never start a sync, so they are safe to poll. Each photo is a reference to a Google-hosted URL, not a copy (a photo you pushed from Synup can keep its source URL until the next scheduled pull replaces it with Google's), and its externalId (the full Google resource name) is its identity: the same URL under two different ids is two photos.
Read owner photos, then customer photos, then the next page
IDs, names and the cursor in these examples are fictional and illustrative, not executable: always pass the actual nextCursor your own response returns. Start with the business's own photos, one per page so the paging is visible.
curl "https://ai.synup.com/api/v1/locations/loc_456/media/google?kind=owner&limit=1" \
-H "Authorization: Bearer $SYNUP_API_KEY"{
"data": {
"items": [
{
"id": "9b2e7c14-5d3a-4f6e-8a21-c0d7f3a1b645",
"associationKey": "9b2e7c14-5d3a-4f6e-8a21-c0d7f3a1b645:loc_456",
"externalId": "accounts/102938475610293847561/locations/5647382910563748291/media/AF1QipOwnerLobby01",
"url": "https://lh3.googleusercontent.com/p/AF1QipOwnerLobby01",
"category": "INTERIOR",
"format": "PHOTO",
"kind": "owner",
"lastSeenAt": "2026-10-12T04:01:58.000Z",
"presence": "seen_in_last_complete_scan",
"moderation": { "status": null },
"location": { "id": "loc_456", "name": "Acme Dental, Downtown", "clientId": "cli_123" },
"freshness": { "coverage": "complete", "outcome": "complete", "lastAttemptAt": "2026-10-12T04:02:11.000Z", "lastSuccessAt": "2026-10-12T04:02:11.000Z", "completedScanAt": "2026-10-12T04:01:58.000Z" }
}
],
"location": {
"id": "loc_456", "name": "Acme Dental, Downtown", "clientId": "cli_123",
"owner": { "coverage": "complete", "outcome": "complete", "lastAttemptAt": "2026-10-12T04:02:11.000Z", "lastSuccessAt": "2026-10-12T04:02:11.000Z", "completedScanAt": "2026-10-12T04:01:58.000Z" },
"ugc": { "coverage": "unavailable", "outcome": "unavailable", "lastAttemptAt": "2026-10-12T04:02:12.000Z", "lastSuccessAt": null, "completedScanAt": null }
},
"limit": 1,
"nextCursor": "NEXT_CURSOR_FROM_THIS_RESPONSE"
}
}The location block comes back on every location read, even with zero items, and carries the owner and customer freshness. Now the customer photos.
curl "https://ai.synup.com/api/v1/locations/loc_456/media/google?kind=ugc" \
-H "Authorization: Bearer $SYNUP_API_KEY"{
"data": {
"items": [],
"location": {
"id": "loc_456", "name": "Acme Dental, Downtown", "clientId": "cli_123",
"owner": { "coverage": "complete", "outcome": "complete", "lastAttemptAt": "2026-10-12T04:02:11.000Z", "lastSuccessAt": "2026-10-12T04:02:11.000Z", "completedScanAt": "2026-10-12T04:01:58.000Z" },
"ugc": { "coverage": "unavailable", "outcome": "unavailable", "lastAttemptAt": "2026-10-12T04:02:12.000Z", "lastSuccessAt": null, "completedScanAt": null }
},
"limit": 50,
"nextCursor": null
}
}An empty items list is not "no customer photos" here. ugc.coverage is unavailable, so Google did not answer for customer photos at the last attempt. Read location before you show a customer an empty gallery. Last, follow nextCursor from the first page, with the same filters.
curl "https://ai.synup.com/api/v1/locations/loc_456/media/google?kind=owner&limit=1&cursor=NEXT_CURSOR_FROM_THIS_RESPONSE" \
-H "Authorization: Bearer $SYNUP_API_KEY"{
"data": {
"items": [
{
"id": "c5d0e3a7-81b4-4a29-b6f2-3e9a7d1c0f58",
"associationKey": "c5d0e3a7-81b4-4a29-b6f2-3e9a7d1c0f58:loc_456",
"externalId": "accounts/102938475610293847561/locations/5647382910563748291/media/AF1QipOwnerFront02",
"url": "https://lh3.googleusercontent.com/p/AF1QipOwnerFront02",
"category": "EXTERIOR",
"format": "PHOTO",
"kind": "owner",
"lastSeenAt": "2026-10-12T04:01:58.000Z",
"presence": "seen_in_last_complete_scan",
"moderation": { "status": null },
"location": { "id": "loc_456", "name": "Acme Dental, Downtown", "clientId": "cli_123" },
"freshness": { "coverage": "complete", "outcome": "complete", "lastAttemptAt": "2026-10-12T04:02:11.000Z", "lastSuccessAt": "2026-10-12T04:02:11.000Z", "completedScanAt": "2026-10-12T04:01:58.000Z" }
}
],
"location": { "id": "loc_456", "...": "same location block as the first page" },
"limit": 1,
"nextCursor": null
}
}nextCursor is null, so that was the last page. A cursor works only with the filters it was issued for. limit takes a whole number from 1 to 100 (default 50) and anything else is a 400, not a clamp. For every location at once, call GET /api/v1/media/google, which also takes locationId, clientId and kind; its location is null only when you do not pass locationId; with locationId it carries that location's coverage even when items is empty. Each item also carries its own.
What the fields promise
- One item per photo and location. A photo shared by several locations appears once for each location you can access. Key rows by
associationKey, notid. presenceis an observation.seen_in_last_complete_scanmeans a Google response included the photo since the last full scan began.not_seen_in_last_complete_scanmeans it was not in that scan. Neither says Google deleted anything, andunknownmeans there is no complete scan to compare with. A photo you pushed after a scan began also counts as seen.- Nothing is hidden. Photos marked
spamorneeds_reviewinmoderation.statusare still returned, and old rows are kept rather than filtered, so apply your own policy. - Access limits what you see. A key limited to specific clients sees only those clients' locations, a location with no client is visible to agency-wide keys only, and a shared photo's other locations are never returned or counted.
- Freshness is honest. A successful refresh is scheduled every 72 hours by a daily 04:00 UTC dispatcher. A missing Google connection or an error shows as
partialorunavailablecoverage, and Google answering 403 or 404 for customer photos can mean unavailable, not empty. Retry eligibility is not a promise that a retry runs at that moment.
From an AI client (MCP)
The list_google_location_media tool reads the same stored data with the same filters (locationId, clientId, kind, limit, cursor) and returns the same page.
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "list_google_location_media",
"arguments": { "locationId": "loc_456", "kind": "owner", "limit": 25 }
}
}It does not refresh anything. fetch_gbp_media is the different tool: it fetches from Google on demand and changes stored data, so use it only when you want a new pull.