Este guia está disponível em inglês.
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
- get
/api/v1/locationsListar / buscar locais - post
/api/v1/locationsCriar um local - patch
/api/v1/locations/{id}Atualizar um local - post
/api/v1/locations/tagsCriar uma tag - post
/api/v1/locations/tags/{id}/locationsAdicionar localizações a uma tag - get
/api/v1/locations/summaryObter resumo de localizações - get
/api/v1/listings/summaryObter um resumo de listagens - get
/api/v1/connections/summaryObter um resumo de contas conectadas - get
/api/v1/workspace/summaryObter resumo do espaço de trabalho
- get
- 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
- Inventory
GET /api/v1/locations?limit=200&cursor=Page through everything Synup has, index it by storeCode - DiffCompare against your master list: missing, changed, closed
- Apply
POST and PATCH, N at a timeA worker pool that backs off on 429 - Verify
GET /api/v1/listings/summaryOne rollup per client, filtered by tag
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
doneasync 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:
| Rollup | What it answers | Scope |
|---|---|---|
| Listings summary | Sync health per location, weakest publishers, ranked "needs attention" items, duplicates and review counts | Client, tags, paginated to 200 rows |
| Locations summary | Counts by status and Google verification state | Client, tags |
| Connections summary | How many locations have Google and Facebook connected | Client, tags |
| Workspace summary | Clients, locations, reviews and SEO across the whole agency | Agency (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
| Situation | What happens | What to do |
|---|---|---|
429 mid-run | Retry-After says how long to wait | Sleep that long, retry the same request, lower concurrency if it repeats. |
Two master records with the same storeCode | Two Synup locations, one map entry | Enforce uniqueness on your side before the run. |
A key scoped to specific clients and a rollup without clientId | 403 | Always pass the client id. |
nextCursor reused after the run | Cursors are opaque and not meant to be stored | Restart paging from the first page each run. |
| A location created twice by a retried request | A duplicate location | Retry creates only after checking the map, or after a list filtered by search on the name and address. |
| Location cap reached | Creation fails | Check getLocationsSummary before an import; raise the cap with your account team. |