Ce guide est disponible en anglais.
Business Listings API: Complete Developer Guide
How a business listings API works and how to create, update, sync and monitor locations across Google, Apple, Bing and directories with the Synup API.
En bref
- Ce que ce guide permet
- Create locations, update their business information, confirm the changes reached every publisher, and handle duplicates, all programmatically.
- API utilisées
- get
/api/v1/clientsLister / rechercher des clients - post
/api/v1/locationsCréer un établissement - get
/api/v1/locations/{id}Obtenir un établissement - patch
/api/v1/locations/{id}Mettre à jour un établissement - get
/api/v1/listingsObtenir la fiche d'un établissement - get
/api/v1/listings/summaryObtenir un résumé des fiches - get
/api/v1/connections/google/connect-urlObtenir une URL de connexion Google - get
/api/v1/connectionsLister les comptes connectés - get
/api/v1/listings/duplicates/rollupObtenir la synthèse des fiches en double - post
/api/v1/locations/{id}/mediaTéléverser des photos vers un établissement
- get
- Référence
- LocationsFiches publiéesConnexionsFiches en double
- Prérequis
- A Synup account with at least one client
- An API key with read and write access to Locations and Listings
- curl, Node.js 18 or later, or Python 3 with the requests package
- Cas d'usage typiques
- A SaaS product pushing customer details to Google, Apple, Bing and directories
- A multi-location brand keeping hundreds of profiles consistent from one source of truth
- An agency automating onboarding and profile changes for its clients
Synup holds one profile per location, name, address, hours, categories, photos, and pushes it out to Google Business Profile, Apple Business Connect, Bing Places and the wider directory network. You write that profile once and update it when your own record changes. Synup queues each change to every publisher the location is connected to.
Everything below builds one integration end to end: find the client, create a location, update it, check that the update reached each publisher, connect Google, look for duplicates. The calls are identical whether you run three locations or three thousand.
How Synup models listings
- Your systemThe source of truth for each location's details
- Synup location
POST or PATCH /api/v1/locationsOne record per physical location, under a client - Sync queueEvery saved change is queued to each connected publisher
- PublishersGoogle, Apple, Bing, Facebook and the directory network
- Listing status
GET /api/v1/listingsPer-publisher sync state you can poll
Five terms recur throughout, so pin them down first.
| Term | Definition |
|---|---|
| Client | The business you manage. Every location belongs to exactly one client. |
| Location | The physical place of business. This is the core record you create and update. |
| Listing | A location's presence on a single publisher, for example the Google listing as opposed to the Bing listing. |
| Connection | An authorised OAuth link to a publisher account, such as a Google login. Directories don't need one; Google and Facebook do. |
| Duplicate | A second listing for the same business on the same publisher, detected by Synup's scans. |
API keys are scoped per client. A key restricted to specific clients returns a 403 for any other client.
Before you start
Every request carries the API key as a Bearer token. Confirm yours works before writing anything:
export SYNUP_API_KEY="sy_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
curl "https://ai.synup.com/api/v1/clients?limit=1" \
-H "Authorization: Bearer $SYNUP_API_KEY"A 200 with a client in data.clients means the key is good. Smoke-test with GET /api/v1/clients, not
GET /api/v1/workspace/summary. A key scoped to specific clients has no agency-wide view, so the workspace
summary answers 403 { "error": "clientId is required for this token" }, and that scoped key is the one you
should be issuing. Errors always come back with an error field:
{ "error": "locationId is required" }See Authentication for key scopes and expiry.
Step 1: Find the client
A location always sits under a client, so you need the client id first. GET /api/v1/clients takes a
free-text search over the business name.
curl "https://ai.synup.com/api/v1/clients?search=Grove%20Street&limit=5" \
-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 findClient(name) {
const params = new URLSearchParams({ search: name, limit: "5" });
const res = await fetch(`${API}/clients?${params}`, { headers });
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
const { data } = await res.json();
return data.clients[0]; // { id, businessName, ... }
}import os
import requests
API = "https://ai.synup.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SYNUP_API_KEY']}"}
def find_client(name):
r = requests.get(f"{API}/clients", headers=HEADERS,
params={"search": name, "limit": 5}, timeout=30)
r.raise_for_status()
return r.json()["data"]["clients"][0] # {"id": ..., "businessName": ..., ...}If the business isn't in Synup yet, create it with POST /api/v1/clients and use the returned id.
Step 2: Create a location
POST /api/v1/locations needs the client id, the business name and a complete street address. Everything
else is optional and can be patched in later. Success returns a 201 and the new locationId.
curl -X POST https://ai.synup.com/api/v1/locations \
-H "Authorization: Bearer $SYNUP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"clientId": "CLIENT_ID",
"name": "Grove Street Dental Mission",
"countryIso": "US",
"street": "88 Grove St",
"city": "San Francisco",
"stateIso": "CA",
"postalCode": "94105",
"phone": "+1 415 555 0142",
"website": "https://grovestreetdental.example/downtown",
"categoryName": "Dentist",
"tags": ["region:west", "tier:1"]
}'async function createLocation(clientId) {
const res = await fetch(`${API}/locations`, {
method: "POST",
headers,
body: JSON.stringify({
clientId,
name: "Grove Street Dental Mission",
countryIso: "US",
street: "88 Grove St",
city: "San Francisco",
stateIso: "CA",
postalCode: "94105",
phone: "+1 415 555 0142",
website: "https://grovestreetdental.example/downtown",
categoryName: "Dentist",
tags: ["region:west", "tier:1"],
}),
});
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
const { data } = await res.json();
return data.locationId;
}def create_location(client_id):
body = {
"clientId": client_id,
"name": "Grove Street Dental Mission",
"countryIso": "US",
"street": "88 Grove St",
"city": "San Francisco",
"stateIso": "CA",
"postalCode": "94105",
"phone": "+1 415 555 0142",
"website": "https://grovestreetdental.example/downtown",
"categoryName": "Dentist",
"tags": ["region:west", "tier:1"],
}
r = requests.post(f"{API}/locations", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()["data"]["locationId"]Three things trip people up here.
categoryName is matched against Synup's general category catalog. Want an exact id? Look it up with
GET /api/v1/locations/categories. Each publisher keeps its own category tree, so pass
publisherCategories as an { id, name } pair per publisher from GET /api/v1/locations/publisher-categories.
latitude and longitude are derived from the address when you omit them. Only send your own if they beat a
geocoder.
Tags never publish anywhere. They only filter the list and rollup endpoints. Tag by brand, region or tier from day one, or you'll be backfilling them later.
Store the returned locationId against your own record. The create call doesn't take a storeCode, so if you
want your own id on the location, send it in a follow-up update. There's no lookup by store code, but every row
in the location list carries it, so you can rebuild the mapping from a single page-through.
Pushing updates
Partial updates go through PATCH /api/v1/locations/{id}. A field you send replaces its current value outright,
so an empty string clears a text field and a list overwrites the whole list. Saved changes are queued for
syndication on their own.
Here's a phone number and a weekly schedule in one call. Times are 24-hour HH:MM, and "24:00" means midnight.
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",
"regularHours": [
{ "day": "MONDAY", "closed": false, "periods": [{ "open": "09:00", "close": "17:00" }] },
{ "day": "TUESDAY", "closed": false, "periods": [{ "open": "09:00", "close": "17:00" }] },
{ "day": "WEDNESDAY", "closed": false, "periods": [{ "open": "09:00", "close": "17:00" }] },
{ "day": "THURSDAY", "closed": false, "periods": [{ "open": "09:00", "close": "19:00" }] },
{ "day": "FRIDAY", "closed": false, "periods": [{ "open": "09:00", "close": "17:00" }] },
{ "day": "SATURDAY", "closed": false, "periods": [{ "open": "10:00", "close": "14:00" }] },
{ "day": "SUNDAY", "closed": true, "periods": [] }
]
}'async function updateLocation(locationId, changes) {
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 data.location; // the full profile after the change
}
const weekday = { closed: false, periods: [{ open: "09:00", close: "17:00" }] };
await updateLocation("LOCATION_ID", {
phone: "+1 415 555 0199",
regularHours: [
{ day: "MONDAY", ...weekday },
{ day: "TUESDAY", ...weekday },
{ day: "WEDNESDAY", ...weekday },
{ day: "THURSDAY", closed: false, periods: [{ open: "09:00", close: "19:00" }] },
{ day: "FRIDAY", ...weekday },
{ day: "SATURDAY", closed: false, periods: [{ open: "10:00", close: "14:00" }] },
{ day: "SUNDAY", closed: true, periods: [] },
],
});def update_location(location_id, changes):
r = requests.patch(f"{API}/locations/{location_id}", headers=HEADERS,
json=changes, timeout=30)
r.raise_for_status()
return r.json()["data"]["location"] # the full profile after the change
weekday = {"closed": False, "periods": [{"open": "09:00", "close": "17:00"}]}
update_location("LOCATION_ID", {
"phone": "+1 415 555 0199",
"regularHours": [
{"day": "MONDAY", **weekday},
{"day": "TUESDAY", **weekday},
{"day": "WEDNESDAY", **weekday},
{"day": "THURSDAY", "closed": False, "periods": [{"open": "09:00", "close": "19:00"}]},
{"day": "FRIDAY", **weekday},
{"day": "SATURDAY", "closed": False, "periods": [{"open": "10:00", "close": "14:00"}]},
{"day": "SUNDAY", "closed": True, "periods": []},
],
})One field you can't touch after creation is the country.
Did it actually sync?
Syndication isn't instant, so don't assume a 200 on the update means the profile is live. Read the
per-publisher state with GET /api/v1/listings.
curl "https://ai.synup.com/api/v1/listings?locationId=LOCATION_ID" \
-H "Authorization: Bearer $SYNUP_API_KEY"async function listingStatus(locationId) {
const res = await fetch(`${API}/listings?locationId=${locationId}`, { headers });
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
const { data } = await res.json();
return data.publishers.map((p) => `${p.publisherName}: ${p.status}`);
}def listing_status(location_id):
r = requests.get(f"{API}/listings", headers=HEADERS,
params={"locationId": location_id}, timeout=30)
r.raise_for_status()
data = r.json()["data"]
return [f"{p['publisherName']}: {p['status']}" for p in data["publishers"]]Each publisher reports one of these:
| Status | What to do |
|---|---|
synced | Nothing. The publisher matches your data. |
in_progress | Poll again later. Updates take anywhere from minutes to a couple of days. |
not_connected | The publisher needs an account connection (Google, Facebook). Connect it below. |
requires_action | Flag it in your UI. Usually a person has to verify something on the publisher's side. |
pending_approval | The publisher is reviewing the listing. Wait. |
failed | Check the profile for invalid data, fix it and save again. |
expired, credentials_invalidated | The connection no longer works. Reconnect the account. |
suspended, inaccessible, not_available | The publisher has restricted or doesn't offer this listing. Handle it on the publisher's side. |
For many locations, don't loop the overview call. GET /api/v1/listings/summary returns the same picture as
one rollup with a per-location table.
Connecting Google Business Profile
Google and Facebook publish through the business's own account, which means an OAuth grant from the owner. The API can't do that step, and there's no way around it.
GET /api/v1/connections/google/connect-url gives you the authorisation URL for a location. Hand it to the owner, then
poll GET /api/v1/connections until the account shows up.
curl "https://ai.synup.com/api/v1/connections/google/connect-url?locationId=LOCATION_ID" \
-H "Authorization: Bearer $SYNUP_API_KEY"async function googleConnectUrl(locationId) {
const res = await fetch(`${API}/connections/google/connect-url?locationId=${locationId}`, { headers });
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
const { data } = await res.json();
return data.url; // surface this URL in your UI for the owner to open
}def google_connect_url(location_id):
r = requests.get(f"{API}/connections/google/connect-url", headers=HEADERS,
params={"locationId": location_id}, timeout=30)
r.raise_for_status()
return r.json()["data"]["url"] # surface this URL in your UI for the owner to openDuplicates
Directories spawn duplicate listings from old addresses, third-party data and customer submissions, and each one
splits reviews and confuses customers. GET /api/v1/listings/duplicates/rollup summarises every detected duplicate
across a client, banded by how confident Synup is that it's the same business.
curl "https://ai.synup.com/api/v1/listings/duplicates/rollup?clientId=CLIENT_ID&band=high" \
-H "Authorization: Bearer $SYNUP_API_KEY"Flagging one queues a removal request to the publisher. The duplicate listings guide walks the review and cleanup loop.
Adding photos
Photos go through POST /api/v1/locations/{id}/media, one category at a time, such as EXTERIOR, INTERIOR or LOGO.
Send a public HTTPS URL, which Synup fetches and re-hosts, or the bytes as base64. Images only, 5 MB each.
LOGO holds one image and replaces it, and every other category appends. Saved photos are queued to Google.
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": "EXTERIOR",
"images": [{ "url": "https://cdn.grovestreetdental.example/downtown/storefront.jpg", "label": "Storefront" }]
}'Working at scale
The single-location loop holds up at volume if you build three habits in early.
- Page with cursors. Pass
nextCursorback ascursoruntil it isnull, and ask forlimit=200to make fewer calls. - Respect 429. Limits scale with your plan. A 429 carries a
Retry-Afterheader, so wait exactly that long and retry. - Keep your own id map. Put your internal id in
storeCode. Location search doesn't query by external id, but the list returnsstoreCodeon every row.
The thousands of locations guide turns these into a full sync job with retries and progress tracking.
Common errors to watch for
| Status code | Cause | Fix |
|---|---|---|
401 | Key missing, expired or revoked. Keys expire after 30, 90 or 365 days. | Generate a new key in Settings. |
403 | The key lacks scope for this write, or is limited to other clients. | Check the key's scopes and client list. |
404 | The location or client isn't in your agency. | Verify the id came from this account. |
409 | State conflict, for example archiving an archived location. | Read the location's current state first. |
422 | Payload validation failed. | Fix the field named in the error message. |
429 | Rate limit. | Wait Retry-After seconds and retry. |