GuideAnleitungen/Manage listings

Diese Anleitung ist auf Englisch verfügbar.

How do I manage business listings programmatically?

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.

Auf einen Blick

Was diese Anleitung leistet
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.
Verwendete APIs
Referenz
LocationsVeröffentlichte Einträge
Voraussetzungen
  • 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
Typische Anwendungsfälle
  • 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

  1. Change detectedA row updated in your database, a form saved, a CSV imported
  2. Diff against SynupGET /api/v1/locations/{id}Only send what differs
  3. UpdatePATCH /api/v1/locations/{id}Synup validates, stores and queues the sync
  4. PublishersEach connected publisher receives the change on its own schedule
  5. VerifyGET /api/v1/listings?locationId=Poll per-publisher status until synced
One change, end to end

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 with closed and a list of { open, close } periods. Send times as 24-hour HH: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 in periods.
  • specialHours: date-specific overrides for holidays or one unusual day. Each entry is { date, closed, open, close } with the date as YYYY-MM-DD. open and close apply when closed is false.
  • moreHours: extra hour types such as delivery, takeout or senior hours. Each entry is { hoursTypeId, label, days }, where days has the same weekly shape as regularHours and hoursTypeId is one of DELIVERY, TAKEOUT, PICKUP, DRIVE_THROUGH, BREAKFAST, LUNCH, DINNER, BRUNCH, HAPPY_HOUR, KITCHEN, ONLINE_SERVICE_HOURS, ACCESS or SENIOR_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

SituationWhat happensWhat to do
Address changed without coordinatesLatitude and longitude are re-derivedExpected. Send coordinates only if you have better data than the geocoder.
Attribute id Google doesn't acceptDropped, listed in rejectedAttributes, request still succeedsLog it; don't retry the same id.
publisherCategories with only id or only nameThe Google category is clearedAlways send both.
Update to a location with verificationStatus: pendingAccepted by Synup, held by Google until verification completesShow the verification state next to the sync status.
More than 9 additionalCategories422Trim the list to nine.
A LOGO uploadReplaces the current logoExpected; compare mediaByCategory.LOGO first if you need to skip.
409 on archiveThe location is already archived or archival is already pendingRead scheduledArchiveAt on the profile.