How to Manage Business Listings Programmatically
Keep business listings accurate from your own system with the Synup API. Covers change detection, idempotent updates, hours, photos and sync reporting.
At a glance
- What this guide accomplishes
- Run an ongoing process that pushes every change in your system of record to Synup, confirms it reached each publisher, and reports listing health back to your users.
- APIs used
- get
/api/v1/locationsList / search locations - get
/api/v1/locations/{id}Get a location - patch
/api/v1/locations/{id}Update a location - post
/api/v1/locations/{id}/mediaUpload photos to a location - get
/api/v1/listingsGet location's listing - get
/api/v1/listings/summaryGet a listings summary - post
/api/v1/locations/{id}/archiveSchedule a location to archive - post
/api/v1/locations/{id}/cancel-archiveCancel a scheduled archival
- get
- Reference
- LocationsListings Published
- Prerequisites
- Locations already created in Synup, with their ids stored against your own records
- An API key with read and write access to Locations and Listings
- A source of truth on your side that can detect state changes
- Typical use cases
- A franchise system that owns the hours, phone numbers and services for every location
- A vertical SaaS product that lets customers edit their profile once and publishes it everywhere
- An operations team closing locations for holidays without touching each publisher by hand
Keeping a listing correct over time is three calls in a loop: read the location, PATCH the fields that differ from your own record, poll the listing status until the publishers catch up. Those three are the easy part. What catches a first integration is the behaviour around them, partial updates that quietly wipe data you never meant to touch, holiday hours, and telling your own users whether a location is actually live. That is most of what follows.
How a listing update flows
- Change detectedA row updated in your database, a form saved, a CSV imported
- Diff against Synup
GET /api/v1/locations/{id}Only send what differs - Update
PATCH /api/v1/locations/{id}Synup validates, stores and queues the sync - PublishersEach connected publisher receives the change on its own schedule
- Verify
GET /api/v1/listings?locationId=Poll per-publisher status until synced
The update itself is synchronous. Publishing downstream is not: directories can take minutes, and publishers that review edits by hand take days.
Read before you write
Read the location before you touch it. GET /api/v1/locations/{id} returns everything an update can change:
address, phones, website, categories, Google attributes, services, hours and media.
Two reasons to read first. Lists and maps replace rather than merge, so to add one Google attribute you need the current set to append to. And diffing against what's already stored keeps your write volume down and your audit log honest.
curl https://ai.synup.com/api/v1/locations/LOCATION_ID \
-H "Authorization: Bearer $SYNUP_API_KEY"const API = "https://ai.synup.com/api/v1";
const headers = {
Authorization: `Bearer ${process.env.SYNUP_API_KEY}`,
"Content-Type": "application/json",
};
async function getLocation(locationId) {
const res = await fetch(`${API}/locations/${locationId}`, { headers });
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
return (await res.json()).data;
}import os
import requests
API = "https://ai.synup.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SYNUP_API_KEY']}"}
def get_location(location_id):
r = requests.get(f"{API}/locations/{location_id}", headers=HEADERS, timeout=30)
r.raise_for_status()
return r.json()["data"]Send only the diff
PATCH /api/v1/locations/{id} takes any subset of the profile. Fields you include overwrite what's there.
Fields you leave out stay put.
The function below compares your desired state against the current profile and sends only the difference, which makes the job idempotent. Run it again with nothing changed and it writes nothing.
curl -X PATCH https://ai.synup.com/api/v1/locations/LOCATION_ID \
-H "Authorization: Bearer $SYNUP_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "phone": "+1 415 555 0199", "website": "https://grovestreetdental.example/downtown" }'const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
async function syncLocation(locationId, desired) {
const current = await getLocation(locationId);
const changes = {};
for (const [field, value] of Object.entries(desired)) {
if (!same(current[field], value)) changes[field] = value;
}
if (Object.keys(changes).length === 0) return { changed: [] };
const res = await fetch(`${API}/locations/${locationId}`, {
method: "PATCH",
headers,
body: JSON.stringify(changes),
});
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
const { data } = await res.json();
return { changed: Object.keys(changes), rejectedAttributes: data.rejectedAttributes ?? [] };
}
await syncLocation("LOCATION_ID", {
phone: "+1 415 555 0199",
website: "https://grovestreetdental.example/downtown",
tags: ["region:west", "tier:1"],
});def sync_location(location_id, desired):
current = get_location(location_id)
changes = {k: v for k, v in desired.items() if current.get(k) != v}
if not changes:
return {"changed": []}
r = requests.patch(f"{API}/locations/{location_id}", headers=HEADERS,
json=changes, timeout=30)
r.raise_for_status()
data = r.json()["data"]
return {"changed": list(changes), "rejectedAttributes": data.get("rejectedAttributes", [])}
sync_location("LOCATION_ID", {
"phone": "+1 415 555 0199",
"website": "https://grovestreetdental.example/downtown",
"tags": ["region:west", "tier:1"],
})A couple of field names don't line up between read and write. The read returns state and country. The write
takes stateIso, and country can't change at all. Attributes read back as a list of { id, label, value } with
value as a string ("true", "false", or a choice), but go out as a flat map of id to value on update. Build
your diff around those, not around a naive key-for-key comparison.
Hours, holidays and closures
Hours live in three lists, and sending any one replaces the whole list.
regularHours: your baseline, one entry per day of the week, each withclosedand a list of{ open, close }periods. Send times as 24-hourHH:MM(a 24-hour day is one period from"00:00"to"24:00"). A read hands them back in 12-hour form instead, for example"08:00am". Yes, the write format and the read format differ, so normalise before you compare or your diff fires on every sync. A split shift is two entries inperiods.specialHours: date-specific overrides for holidays or one unusual day. Each entry is{ date, closed, open, close }with the date asYYYY-MM-DD.openandcloseapply whenclosedis false.moreHours: extra hour types such as delivery, takeout or senior hours. Each entry is{ hoursTypeId, label, days }, wheredayshas the same weekly shape asregularHoursandhoursTypeIdis one ofDELIVERY,TAKEOUT,PICKUP,DRIVE_THROUGH,BREAKFAST,LUNCH,DINNER,BRUNCH,HAPPY_HOUR,KITCHEN,ONLINE_SERVICE_HOURS,ACCESSorSENIOR_HOURS.
A holiday is one specialHours entry, and the regular schedule covers the rest of the week. A month-long
renovation is one specialHours entry per closed day. Don't reach for archival to close a location temporarily. Archiving
tells publishers the business is gone for good.
curl -X PATCH https://ai.synup.com/api/v1/locations/LOCATION_ID \
-H "Authorization: Bearer $SYNUP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"specialHours": [
{ "date": "2026-12-25", "closed": true },
{ "date": "2026-12-31", "closed": false, "open": "09:00", "close": "13:00" }
],
"moreHours": [
{
"hoursTypeId": "DELIVERY",
"days": [
{ "day": "MONDAY", "closed": false, "periods": [{ "open": "11:00", "close": "21:00" }] },
{ "day": "TUESDAY", "closed": false, "periods": [{ "open": "11:00", "close": "21:00" }] },
{ "day": "SUNDAY", "closed": true, "periods": [] }
]
}
]
}'These item shapes come from the update_location tool in the MCP catalog, which types
them in full. The REST reference lists the items as plain objects.
Photos
POST /api/v1/locations/{id}/media adds images to one category per call: COVER, PROFILE, LOGO,
EXTERIOR, INTERIOR, PRODUCT, FOOD_AND_DRINK, MENU, AT_WORK, TEAMS, ROOMS, COMMON_AREA or
ADDITIONAL.
Give it a public HTTPS URL and Synup fetches and re-hosts the file, or send the bytes as base64. LOGO keeps
exactly one image and overwrites the old one, and every other category appends. If this runs on a schedule,
check
mediaByCategory from the profile first, or a re-run uploads the same photo twice. Images only, 5 MB each.
curl -X POST https://ai.synup.com/api/v1/locations/LOCATION_ID/media \
-H "Authorization: Bearer $SYNUP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"category": "INTERIOR",
"images": [
{ "url": "https://cdn.grovestreetdental.example/downtown/lobby.jpg", "label": "Lobby" },
{ "url": "https://cdn.grovestreetdental.example/downtown/room-2.jpg", "label": "Treatment room" }
]
}'Confirm it reached the publishers
GET /api/v1/listings returns publisher statuses for one location. After a worker patches 500 of
them, don't loop that call. GET /api/v1/listings/summary answers for the whole set at once: a per-location
table of synced against publishers counts, up to 200 rows a page, plus ranked items that need attention.
curl "https://ai.synup.com/api/v1/listings/summary?clientId=CLIENT_ID&tags=region:west&perPage=200" \
-H "Authorization: Bearer $SYNUP_API_KEY"async function unsyncedLocations(clientId, tags) {
const params = new URLSearchParams({ clientId, tags: tags.join(","), perPage: "200" });
const res = await fetch(`${API}/listings/summary?${params}`, { headers });
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
const { data } = await res.json();
return data.rows.filter((row) => row.synced < row.publishers);
}def unsynced_locations(client_id, tags):
r = requests.get(f"{API}/listings/summary", headers=HEADERS, timeout=30,
params={"clientId": client_id, "tags": ",".join(tags), "perPage": 200})
r.raise_for_status()
rows = r.json()["data"]["rows"]
return [row for row in rows if row["synced"] < row["publishers"]]The location list itself carries two fields worth a dashboard column: pendingChanges, the count of saved edits
not yet synced, and lastPublishedAt, the last time anything reached a publisher. GET /api/v1/locations
returns both on every row.
Opening and closing locations
Creating a location is a plain POST, and the complete guide covers the required
fields. Closing one for good is POST /api/v1/locations/{id}/archive.
Archival isn't immediate. It's scheduled for the end of the current billing period, which leaves a window to call
POST /api/v1/locations/{id}/cancel-archive if a user hit the button by mistake. Once scheduled, the location drops out
of the default list unless you pass status=archived to GET /api/v1/locations.
Reporting sync health
Your users don't care about API queues. They want one number and a short list of what needs attention.
insights.health from the summary endpoint is the number: synced publisher slots as a percentage of all slots
in scope. Next to it, insights.attention gives the list, each card carrying a count, a title and a filter
key (issues, duplicates, under80, unverified, notconnected) that maps to a view of the per-location
rows. Synup computes both, so the whole dashboard is one request per client.
What catches people out
| Situation | What happens | What to do |
|---|---|---|
| Address changed without coordinates | Latitude and longitude are re-derived | Expected. Send coordinates only if you have better data than the geocoder. |
| Attribute id Google doesn't accept | Dropped, listed in rejectedAttributes, request still succeeds | Log it; don't retry the same id. |
publisherCategories with only id or only name | The Google category is cleared | Always send both. |
Update to a location with verificationStatus: pending | Accepted by Synup, held by Google until verification completes | Show the verification state next to the sync status. |
More than 9 additionalCategories | 422 | Trim the list to nine. |
A LOGO upload | Replaces the current logo | Expected; compare mediaByCategory.LOGO first if you need to skip. |
409 on archive | The location is already archived or archival is already pending | Read scheduledArchiveAt on the profile. |