GuideGuias/Thousands of locations

Este guia está disponível em inglês.

How do I manage hundreds or thousands of business locations with an API?

How to Manage Thousands of Business Locations via API

Design a sync job for thousands of locations on the Synup API with cursor pagination, tags, rate-limit handling, an id map and rollup health checks.

Em resumo

O que este guia realiza
Build a repeatable job that inventories every location, creates the missing ones, applies changes in bulk without tripping rate limits, and reports health across the whole estate in a handful of calls.
APIs utilizadas
Referência
LocationsListagens publicadasConnections
Pré-requisitos
  • A master list of locations on your side with a stable id per location
  • An API key with read and write access to Locations and Listings, scoped to the clients involved
  • The complete guide's create and update calls, which this guide runs at volume
Casos de uso típicos
  • A franchise or retail brand syncing every store from an internal master data system
  • A platform onboarding a customer with hundreds of locations from a spreadsheet
  • An agency rolling a holiday schedule or a rebrand out to every client at once

Managing thousands of locations is the same three calls as managing one: list, create, update. What changes is the job around them, and it lives inside four constraints. Results page at up to 200 rows. Nothing looks a location up by your own id. Rate limits are per agency. Every write publishes asynchronously. Build for those four and the job runs the same at a hundred locations or at fifty thousand. The rollup calls at the end report on the whole estate without a call per location.

The shape of a sync job

  1. InventoryGET /api/v1/locations?limit=200&cursor=Page through everything Synup has, index it by storeCode
  2. DiffCompare against your master list: missing, changed, closed
  3. ApplyPOST and PATCH, N at a timeA worker pool that backs off on 429
  4. VerifyGET /api/v1/listings/summaryOne rollup per client, filtered by tag
A nightly sync across an estate

Build the id map from storeCode

Synup identifies a location by its own id, and there's no endpoint that looks one up by your identifier. There is a field for it, though: storeCode, "your internal store code or reference for this location", returned in every location list row and settable with an update. Set it to your id on every location, and the inventory step can rebuild the whole map from one page-through.

GET /api/v1/locations returns up to 200 rows per page with a nextCursor. Pass it back as cursor until it comes back null, and filter by clientId when you work one client at a time.

curl "https://ai.synup.com/api/v1/locations?clientId=CLIENT_ID&limit=200" \
  -H "Authorization: Bearer $SYNUP_API_KEY"
# then, with the nextCursor from the response:
curl "https://ai.synup.com/api/v1/locations?clientId=CLIENT_ID&limit=200&cursor=NEXT_CURSOR" \
  -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* allLocations(clientId) {
  let cursor = "";
  do {
    const params = new URLSearchParams({ clientId, limit: "200", cursor });
    const res = await fetch(`${API}/locations?${params}`, { headers });
    if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
    const { data } = await res.json();
    yield* data.locations;
    cursor = data.nextCursor ?? "";
  } while (cursor);
}

async function idMap(clientId) {
  const map = new Map(); // storeCode -> Synup location row
  for await (const row of allLocations(clientId)) {
    if (row.storeCode) map.set(row.storeCode, row);
  }
  return map;
}
import os
import requests

API = "https://ai.synup.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SYNUP_API_KEY']}"}

def all_locations(client_id):
    cursor = ""
    while True:
        r = requests.get(f"{API}/locations", headers=HEADERS, timeout=30,
                         params={"clientId": client_id, "limit": 200, "cursor": cursor})
        r.raise_for_status()
        data = r.json()["data"]
        yield from data["locations"]
        cursor = data.get("nextCursor") or ""
        if not cursor:
            return

def id_map(client_id):
    return {row["storeCode"]: row for row in all_locations(client_id) if row.get("storeCode")}

Ten thousand locations is fifty requests. Cache the map for the run. Don't rebuild it per location.

Create what's missing

For every master record missing from the map, call POST /api/v1/locations, then PATCH /api/v1/locations/{id} to set storeCode to your id. The create call doesn't take it, so a new location is two requests, run through the same worker pool that carries the updates below. Creation is where validation errors cluster. A 422 names the field, usually an address that doesn't resolve or a category that isn't in the catalog. Record the failure against its store code and keep going. One bad row shouldn't stop the run.

Plan limits apply here too. An agency at its plan's location cap can't create more, so check GET /api/v1/locations/summary for the current total before a large import and confirm the headroom with your account team.

Tag for segmentation

Every list and rollup endpoint filters by tags, so tagging is how a job works on one region, brand or tier at a time. Tags are strings you pick. Set them inline through the location's tags list on create and update, or manage them as objects per client. POST /api/v1/locations/tags makes one, and POST /api/v1/locations/tags/{id}/locations applies it to a list of location ids in a single call, reporting which were added and which already had it.

curl -X POST https://ai.synup.com/api/v1/locations/tags \
  -H "Authorization: Bearer $SYNUP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "clientId": "CLIENT_ID", "name": "region:west" }'

curl -X POST https://ai.synup.com/api/v1/locations/tags/TAG_ID/locations \
  -H "Authorization: Bearer $SYNUP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "locationIds": ["LOC_1", "LOC_2", "LOC_3"] }'
async function tagLocations(clientId, name, locationIds) {
  const created = await fetch(`${API}/locations/tags`, {
    method: "POST",
    headers,
    body: JSON.stringify({ clientId, name }),
  });
  if (!created.ok) throw new Error(`${created.status}: ${(await created.json()).error}`);
  const { data: tag } = await created.json();

  const applied = await fetch(`${API}/locations/tags/${tag.id}/locations`, {
    method: "POST",
    headers,
    body: JSON.stringify({ locationIds }),
  });
  if (!applied.ok) throw new Error(`${applied.status}: ${(await applied.json()).error}`);
  return (await applied.json()).data; // { added: [...], skipped: [...] }
}
def tag_locations(client_id, name, location_ids):
    r = requests.post(f"{API}/locations/tags", headers=HEADERS, timeout=30,
                      json={"clientId": client_id, "name": name})
    r.raise_for_status()
    tag = r.json()["data"]
    r = requests.post(f"{API}/locations/tags/{tag['id']}/locations", headers=HEADERS, timeout=30,
                      json={"locationIds": location_ids})
    r.raise_for_status()
    return r.json()["data"]  # {"added": [...], "skipped": [...]}

A tag belongs to one client. For an agency-wide segment, create the same tag name under each client. The rollup endpoints match on the name.

Apply changes through a worker pool

Rate limits are per agency and scale with your plan, and the API doesn't publish the number. What it does publish is the contract: a 429 with a Retry-After header holding the seconds to wait. A pool of a few workers that pauses on 429 and resumes after the header expires finds the ceiling on its own and stays under it. Keep the diff logic from the manage listings guide so an unchanged location costs one GET and no PATCH.

# Shell scripts do not pool well. Use the JavaScript or Python worker, or
# run a batch tool that honours Retry-After. A single retrying call looks like:
until curl -s -o /tmp/out.json -w "%{http_code}" -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" }' | grep -qv 429; do
  sleep 5
done
async function request(path, init, attempt = 0) {
  const res = await fetch(`${API}${path}`, { ...init, headers });
  if (res.status === 429 && attempt < 6) {
    const wait = Number(res.headers.get("retry-after") ?? 2 ** attempt);
    await new Promise((r) => setTimeout(r, wait * 1000));
    return request(path, init, attempt + 1);
  }
  if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
  return (await res.json()).data;
}

async function pool(items, worker, concurrency = 4) {
  const results = [];
  let next = 0;
  await Promise.all(
    Array.from({ length: concurrency }, async () => {
      while (next < items.length) {
        const item = items[next++];
        try {
          results.push({ item, ok: true, value: await worker(item) });
        } catch (error) {
          results.push({ item, ok: false, error: String(error) });
        }
      }
    })
  );
  return results;
}

// changes: [{ locationId, patch }]
const outcome = await pool(changes, ({ locationId, patch }) =>
  request(`/locations/${locationId}`, { method: "PATCH", body: JSON.stringify(patch) })
);
console.log(`${outcome.filter((r) => r.ok).length} updated, ${outcome.filter((r) => !r.ok).length} failed`);
import time
from concurrent.futures import ThreadPoolExecutor

def request(method, path, json=None, attempt=0):
    r = requests.request(method, f"{API}{path}", headers=HEADERS, json=json, timeout=30)
    if r.status_code == 429 and attempt < 6:
        wait = float(r.headers.get("Retry-After", 2 ** attempt))
        time.sleep(wait)
        return request(method, path, json, attempt + 1)
    r.raise_for_status()
    return r.json()["data"]

def apply(change):
    location_id, patch = change
    try:
        return {"locationId": location_id, "ok": True, "value": request("PATCH", f"/locations/{location_id}", patch)}
    except requests.HTTPError as e:
        return {"locationId": location_id, "ok": False, "error": e.response.text}

# changes: [(location_id, patch), ...]
with ThreadPoolExecutor(max_workers=4) as pool:
    outcome = list(pool.map(apply, changes))
print(f"{sum(r['ok'] for r in outcome)} updated, {sum(not r['ok'] for r in outcome)} failed")

Start at four workers. Never see a 429, raise it. See them constantly, lower it. Log every failure with its store code and the error string, and make the job resumable so a run killed halfway picks up from the diff rather than from the top.

Agents on the MCP server get a bulk_update_locations tool that applies one change to many locations in a single call. The REST API has no equivalent, so the pool above is the REST answer.

Verify across the estate

You don't need a call per location to know how the estate is doing. Four rollups cover it:

RollupWhat it answersScope
Listings summarySync health per location, weakest publishers, ranked "needs attention" items, duplicates and review countsClient, tags, paginated to 200 rows
Locations summaryCounts by status and Google verification stateClient, tags
Connections summaryHow many locations have Google and Facebook connectedClient, tags
Workspace summaryClients, locations, reviews and SEO across the whole agencyAgency (all-clients key only)

The workspace summary is agency-wide, so it needs a key with All-clients access. A key scoped to specific clients gets 403 { "error": "clientId is required for this token" } and has to use the per-client rollups above. The listings summary's insights.health is a single percentage of publisher slots in sync, and its insights.attention cards carry a filter key that maps to the per-location rows, so an estate-wide dashboard is one request per client plus paging.

curl "https://ai.synup.com/api/v1/listings/summary?clientId=CLIENT_ID&tags=region:west&page=1&perPage=200" \
  -H "Authorization: Bearer $SYNUP_API_KEY"

Where a bulk run goes wrong

SituationWhat happensWhat to do
429 mid-runRetry-After says how long to waitSleep that long, retry the same request, lower concurrency if it repeats.
Two master records with the same storeCodeTwo Synup locations, one map entryEnforce uniqueness on your side before the run.
A key scoped to specific clients and a rollup without clientId403Always pass the client id.
nextCursor reused after the runCursors are opaque and not meant to be storedRestart paging from the first page each run.
A location created twice by a retried requestA duplicate locationRetry creates only after checking the map, or after a list filtered by search on the name and address.
Location cap reachedCreation failsCheck getLocationsSummary before an import; raise the cap with your account team.