Ce guide est disponible en anglais.
How to Find and Remove Duplicate Business Listings
Detect duplicate business listings across publishers, review them by confidence, and flag them for removal or dismiss them with the Synup API.
En bref
- Ce que ce guide permet
- Pull every detected duplicate for a client or location, decide which are real, queue removal requests to the publishers, and track them until they are gone.
- API utilisées
- get
/api/v1/listings/duplicates/rollupObtenir la synthèse des fiches en double - get
/api/v1/listings/duplicatesObtenir les fiches en double d'un établissement - post
/api/v1/listings/duplicates/resolveRésoudre des fiches en double - get
/api/v1/listingsObtenir la fiche d'un établissement - get
/api/v1/listings/summaryObtenir un résumé des fiches
- get
- Référence
- Fiches en doubleFiches publiées
- Prérequis
- Locations already created in Synup, with at least one scan completed
- An API key with read and write access to Listings
- The client id, or a key scoped to the client
- Cas d'usage typiques
- A listings product that shows customers their duplicates and cleans them up in one click
- A brand cleaning up years of address changes across hundreds of locations
- An agency running a monthly duplicate sweep for every client
Duplicate listings are found for you. Synup scans each publisher for listings that match a location's name,
address and phone, scores how likely each one is to be the same business, and keeps the results in a queue with
four states: potential, flagged, deleted and failed. Your job through the API is to read that queue,
decide what is a real duplicate, and call one endpoint to flag it for removal or dismiss it. Removal itself is
carried out by Synup against the publisher in the background.
How duplicate detection works
Every location's own listing on a publisher is the original. Anything else on that publisher that looks like the
same business is a candidate. For each candidate Synup records which of the three fields match
(businessNameMatches, addressMatches, phoneMatches), a matchScore between 0 and 1, and a confidence
band of high, med or low. Rows arrive as potential and stay there until someone acts on them.
- ScanSynup finds candidates on each publisher and scores them
- potential
GET /api/v1/listings/duplicates/rollupWaiting for a decision - flagged
POST /api/v1/listings/duplicates/resolveRemoval requested from the publisher - deleted or failedThe publisher removed it, or refused. Failed rows can be flagged again
Dismissing a row marks it as not a duplicate and takes it out of the queue. Flagging never deletes anything on its
own: it queues a removal request that a background job carries to the publisher, and the row moves to deleted
or failed when the publisher answers.
Read the queue across a client
GET /api/v1/listings/duplicates/rollup returns a summary and a paginated queue across every location of a client,
or the whole agency when clientId is omitted. The summary tells you how many potential duplicates exist, how
many locations are affected, the split by confidence band, and which publishers are the worst. Start with the
high-confidence band. Those rows are almost always real.
Duplicates cluster on the aggregator directories far more than on Google or Facebook, so filtering by one
publisherSiteId and clearing the worst publishers first knocks out most of the queue in a few passes. Do not
read too much into the three match flags on their own: a row can carry matchScore: 1 and still show
businessNameMatches: false when the duplicate abbreviates the name, so trust the score and the band over the
individual booleans.
curl "https://ai.synup.com/api/v1/listings/duplicates/rollup?clientId=CLIENT_ID&filter=potential&band=high&sort=confidence&perPage=100" \
-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* potentialDuplicates(clientId, band = "high") {
for (let page = 1; ; page++) {
const params = new URLSearchParams({ clientId, filter: "potential", band, sort: "confidence", page: String(page), perPage: "100" });
const res = await fetch(`${API}/listings/duplicates/rollup?${params}`, { headers });
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
const { data } = await res.json();
yield* data.queue.rows;
if (page * data.queue.perPage >= data.queue.total) return;
}
}import os
import requests
API = "https://ai.synup.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SYNUP_API_KEY']}"}
def potential_duplicates(client_id, band="high"):
page = 1
while True:
r = requests.get(f"{API}/listings/duplicates/rollup", headers=HEADERS, timeout=30,
params={"clientId": client_id, "filter": "potential", "band": band,
"sort": "confidence", "page": page, "perPage": 100})
r.raise_for_status()
queue = r.json()["data"]["queue"]
yield from queue["rows"]
if page * queue["perPage"] >= queue["total"]:
return
page += 1Each row carries both sides of the comparison, so you can show a reviewer the duplicate's businessName,
address and phone next to the location's own yourName, yourAddress and yourPhone, plus
duplicateUrl and originalUrl to open the live listings.
Read one location's duplicates
For a location detail page, GET /api/v1/listings/duplicates returns that location's duplicates grouped by
publisher, with counts for every status regardless of the status filter you pass. yourListing holds the
location's own name, address and phone for the comparison.
curl "https://ai.synup.com/api/v1/listings/duplicates?locationId=LOCATION_ID&status=potential" \
-H "Authorization: Bearer $SYNUP_API_KEY"The same numbers appear as stats.duplicates in GET /api/v1/listings and as the duplicates
column of GET /api/v1/listings/summary, which is the cheap way to find locations that need a sweep.
Flag or dismiss
POST /api/v1/listings/duplicates/resolve takes an action of flag or dismiss and either an explicit list of
row ids or a filter. Id mode fits a reviewer's decisions. Filter mode fits policy, like flagging every
high-confidence row on one publisher in a single call.
# Flag two reviewed rows for removal
curl -X POST https://ai.synup.com/api/v1/listings/duplicates/resolve \
-H "Authorization: Bearer $SYNUP_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "action": "flag", "duplicateListingIds": ["DUP_ID_1", "DUP_ID_2"] }'
# Flag every high-confidence potential duplicate on one publisher for one client
curl -X POST https://ai.synup.com/api/v1/listings/duplicates/resolve \
-H "Authorization: Bearer $SYNUP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"action": "flag",
"clientId": "CLIENT_ID",
"filter": { "filter": "potential", "band": "high", "publisherSiteId": 12 }
}'async function resolveDuplicates(action, duplicateListingIds) {
const res = await fetch(`${API}/listings/duplicates/resolve`, {
method: "POST",
headers,
body: JSON.stringify({ action, duplicateListingIds }),
});
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
const { data } = await res.json();
return data; // { updated, skipped: [ids that did not transition] }
}
// Reviewer approved two rows and rejected one
await resolveDuplicates("flag", ["DUP_ID_1", "DUP_ID_2"]);
await resolveDuplicates("dismiss", ["DUP_ID_3"]);def resolve_duplicates(action, duplicate_listing_ids):
r = requests.post(f"{API}/listings/duplicates/resolve", headers=HEADERS, timeout=30,
json={"action": action, "duplicateListingIds": duplicate_listing_ids})
r.raise_for_status()
return r.json()["data"] # {"updated": n, "skipped": [ids that did not transition]}
resolve_duplicates("flag", ["DUP_ID_1", "DUP_ID_2"])
resolve_duplicates("dismiss", ["DUP_ID_3"])Up to 1,000 ids go in one call. skipped lists any id that didn't change state, whether it was not found, not
owned by your agency or client, or not in a status the action accepts. Filter mode runs as one bulk update over
exactly the rows the same filter would show in the rollup and can't reach past them, so its skipped list is
always empty.
The publisherSiteId for a filter comes from the rollup's summary.publisherSites, which lists every publisher
with a visible duplicate in scope along with its numeric site id.
Track removals to completion
A flagged row moves to deleted once the publisher removes the listing, or to failed if it refuses or the
listing can't be reached. Poll the rollup with filter=flagged on a daily schedule and report both outcomes to
whoever asked for the cleanup. A failed row can be flagged again. Some publishers just need a second request.
Others only act when the business owner contacts them directly, so surface that in your UI rather than retrying
forever.
- Sweep
rollup filter=potential band=highDaily, per client - ReviewAuto-flag high band, queue med band for a person, ignore low unless two fields match
- Resolve
POST .../resolveflag or dismiss in batches of ids - Verify
rollup filter=flaggedWatch rows leave for deleted or failed
What to watch
| Situation | What happens | What to do |
|---|---|---|
A scoped key calls the rollup without clientId | 403 | Pass the client id; there's no agency-wide default for a scoped key. |
Both duplicateListingIds and filter in one resolve | 400 | They are mutually exclusive. Send one or the other. |
Flagging a row that is already deleted | The id appears in skipped | Nothing. The listing is already gone. |
A low band row with all three fields matching | Rare, but the score is null when unscored | Treat a null matchScore with three matches as high. |
| The same duplicate found on two publishers | Two rows, one per publisher | Resolve each. Publishers are independent. |
| A new location with no scan yet | Empty queue, found: true | Wait for the first scan before reporting "no duplicates". |
Two details shape the design. The rollup pages with page and perPage rather than a cursor, and it's sorted
by confidence by default, so page 1 of the high band is where an automatic policy should act. And dismissing
doesn't stop future detection: if a listing changes, a later scan may score it again.