GuideGuides/Business listings API

Ce guide est disponible en anglais.

What API can I use to manage business listings?

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
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

  1. Your systemThe source of truth for each location's details
  2. Synup locationPOST or PATCH /api/v1/locationsOne record per physical location, under a client
  3. Sync queueEvery saved change is queued to each connected publisher
  4. PublishersGoogle, Apple, Bing, Facebook and the directory network
  5. Listing statusGET /api/v1/listingsPer-publisher sync state you can poll
From your system to the publishers

Five terms recur throughout, so pin them down first.

TermDefinition
ClientThe business you manage. Every location belongs to exactly one client.
LocationThe physical place of business. This is the core record you create and update.
ListingA location's presence on a single publisher, for example the Google listing as opposed to the Bing listing.
ConnectionAn authorised OAuth link to a publisher account, such as a Google login. Directories don't need one; Google and Facebook do.
DuplicateA 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:

StatusWhat to do
syncedNothing. The publisher matches your data.
in_progressPoll again later. Updates take anywhere from minutes to a couple of days.
not_connectedThe publisher needs an account connection (Google, Facebook). Connect it below.
requires_actionFlag it in your UI. Usually a person has to verify something on the publisher's side.
pending_approvalThe publisher is reviewing the listing. Wait.
failedCheck the profile for invalid data, fix it and save again.
expired, credentials_invalidatedThe connection no longer works. Reconnect the account.
suspended, inaccessible, not_availableThe 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 open

Duplicates

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.

  1. Page with cursors. Pass nextCursor back as cursor until it is null, and ask for limit=200 to make fewer calls.
  2. Respect 429. Limits scale with your plan. A 429 carries a Retry-After header, so wait exactly that long and retry.
  3. Keep your own id map. Put your internal id in storeCode. Location search doesn't query by external id, but the list returns storeCode on 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 codeCauseFix
401Key missing, expired or revoked. Keys expire after 30, 90 or 365 days.Generate a new key in Settings.
403The key lacks scope for this write, or is limited to other clients.Check the key's scopes and client list.
404The location or client isn't in your agency.Verify the id came from this account.
409State conflict, for example archiving an archived location.Read the location's current state first.
422Payload validation failed.Fix the field named in the error message.
429Rate limit.Wait Retry-After seconds and retry.