GuideGuides/Duplicate listings
How do I find and remove duplicate business listings with an API?

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.

At a glance

What this guide accomplishes
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.
APIs used
Reference
Duplicate ListingsListings Published
Prerequisites
  • 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
Typical use cases
  • 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.

  1. ScanSynup finds candidates on each publisher and scores them
  2. potentialGET /api/v1/listings/duplicates/rollupWaiting for a decision
  3. flaggedPOST /api/v1/listings/duplicates/resolveRemoval requested from the publisher
  4. deleted or failedThe publisher removed it, or refused. Failed rows can be flagged again
The duplicate lifecycle

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 += 1

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

  1. Sweeprollup filter=potential band=highDaily, per client
  2. ReviewAuto-flag high band, queue med band for a person, ignore low unless two fields match
  3. ResolvePOST .../resolveflag or dismiss in batches of ids
  4. Verifyrollup filter=flaggedWatch rows leave for deleted or failed
A review workflow built on these calls

What to watch

SituationWhat happensWhat to do
A scoped key calls the rollup without clientId403Pass the client id; there's no agency-wide default for a scoped key.
Both duplicateListingIds and filter in one resolve400They are mutually exclusive. Send one or the other.
Flagging a row that is already deletedThe id appears in skippedNothing. The listing is already gone.
A low band row with all three fields matchingRare, but the score is null when unscoredTreat a null matchScore with three matches as high.
The same duplicate found on two publishersTwo rows, one per publisherResolve each. Publishers are independent.
A new location with no scan yetEmpty queue, found: trueWait 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.