Audit a business's listings with a listings scan

Check how any business appears across maps, search engines, directories and AI assistants, read the name, address and phone accuracy per directory, and get the results by webhook or by polling.

A listings scan checks how one business is listed across the listingsAPI directory network and tells you, directory by directory, whether it is found and whether its name, address and phone match what you gave. The business does not have to be a location in your account. This guide creates a scan, waits for it to finish, and reads the results.

Use it to:

  • audit a prospect before you onboard them, so the first conversation starts with the listings that are missing or wrong;
  • check a client's presence and NAP (name, address, phone) consistency before and after you fix their listings;
  • give an AI agent a way to answer "how is this business listed?" without adding the business to the account.

1. Create the scan

POST /listings/scans takes the business as a single input object and returns immediately with the scan in QUEUED. The directories are checked in the background.

FieldRequiredNotes
nameYesUp to 150 characters.
streetYesUp to 200 characters.
cityYesUp to 100 characters.
stateIsoYes, except GBState or province code, up to 50 characters.
postalCodeYes, except NG and AEUp to 20 characters.
countryIsoYesTwo-letter ISO 3166-1 code, for example US.
phoneYes6 to 15 digits. Local formats are read in the context of countryIso, and a leading + works too.
Create a scan
curl -X POST https://listingsapi.com/api/v4/listings/scans \-H "Authorization: API $LISTINGSAPI_KEY" \-H "Content-Type: application/json" \-d @- <<'JSON'{"input": {  "name": "Joe's Pizza",  "street": "7 Carmine Street",  "city": "New York",  "stateIso": "NY",  "postalCode": "10014",  "countryIso": "US",  "phone": "2123661182"}}JSON

The response returns the scan, with phone normalized to international format. results is empty until directories are checked, so keep the id.

JSON
{  "data": {    "createListingScan": {      "reused": false,      "scan": {        "id": "TGlzdGluZ1NjYW46Mzk0YTRhNWMtZjcwMy00NjZiLTg2NjAtMDU4MTlmNDhkZjMz",        "status": "QUEUED",        "source": "DIRECTORY_SEARCH",        "locationId": null,        "resultsAsOf": null,        "createdAt": "2026-11-05T07:01:08Z",        "startedAt": null,        "completedAt": null,        "business": {          "name": "Joe's Pizza",          "street": "7 Carmine Street",          "city": "New York",          "stateIso": "NY",          "postalCode": "10014",          "countryIso": "US",          "phone": "+12123661182"        },        "summary": {          "total": 0,          "found": 0,          "accurate": 0,          "inaccurate": 0,          "notFound": 0,          "pending": 0,          "unavailable": 0        },        "failure": null,        "results": []      },      "errors": []    }  }}

The response also includes a quota object, covered in Costs, limits and errors.

Two things to check before you move on:

  • reused. When you ask for a business you scanned in the last seven days, listingsAPI returns that scan instead of starting a new one, with reused: true and its current status, usually COMPLETED. Nothing is charged to your allowance, and no new webhook is sent for it.
  • errors. A rejected request still answers HTTP 200. The rejection is in data.createListingScan.errors, and scan is null. A phone number that is too short looks like this:
JSON
{  "data": {    "createListingScan": {      "reused": false,      "scan": null,      "errors": [        {          "code": "SY83003",          "message": "SY83003: Invalid value for phone",          "contextInfo": [            {              "key": "field",              "value": "phone"            }          ]        }      ]    }  }}

contextInfo names the field that failed. The other codes you can get are in the error table.

2. Wait for the results

A scan moves from QUEUED to IN_PROGRESS to COMPLETED or FAILED. Most finish in one to seven minutes. If a directory has not answered after about 15 minutes, its result is set to UNAVAILABLE and the scan completes with the other results. Use a webhook if you can, and poll only as a fallback.

When the scan reaches a final state, listingsAPI sends one listing_scan.completed or listing_scan.failed event to your webhook URL. Its data.scan is the same object GET returns, results included, so there is nothing to fetch afterwards.

JSON
{  "event": "listing_scan.completed",  "timestamp": "2026-11-05T07:01:52Z",  "account_id": 12345,  "data": {    "scan": {      "id": "TGlzdGluZ1NjYW46Mzk0YTRhNWMtZjcwMy00NjZiLTg2NjAtMDU4MTlmNDhkZjMz",      "status": "COMPLETED",      "summary": { "total": 62, "found": 21, "accurate": 18, "inaccurate": 2, "notFound": 41, "pending": 0, "unavailable": 0 },      "results": [ "..." ]    }  }}

The payload is shortened here; the full example is in the event reference. Events are signed and delivered like every other event, with one attempt and a five-second budget, so verify the signature, store the raw body, and acknowledge before you do anything else. The receiver in React to events with webhooks already does this; the only change is one more branch on event.

One more branch in your receiver
# After the signature check and the endpoint.verification branch:if body.get("event") in ("listing_scan.completed", "listing_scan.failed"):  inbox.put(raw)  # commit before the 200; read data.scan from the queue  return {"received": True}, 200

An event goes only to accounts with webhooks enabled and a verified URL. If yours is not, or if an event never arrives, poll the scan; the scan is kept for 90 days either way. Signing details are in Signatures and verification.

2b. Poll as a fallback

GET /listings/scans/{scanId} returns the scan with whatever results exist so far. While the scan runs, results fills in as directories answer and summary.pending counts the ones still open. Poll every 15 seconds and no faster; each request counts toward your rate limit.

Poll until the scan finishes
SCAN_ID="TGlzdGluZ1NjYW46Mzk0YTRhNWMtZjcwMy00NjZiLTg2NjAtMDU4MTlmNDhkZjMz" while true; doscan=$(curl -s "https://listingsapi.com/api/v4/listings/scans/$SCAN_ID" \  -H "Authorization: API $LISTINGSAPI_KEY")status=$(echo "$scan" | jq -r '.data.listingScan.status')echo "status: $status"case "$status" in  COMPLETED|FAILED)    echo "$scan" | jq '.data.listingScan | {status, summary, failure}'    break ;;  null)    echo "$scan"    break ;;esacsleep 15done
statusMeaning
QUEUEDAccepted and waiting to start.
IN_PROGRESSDirectories are being checked. results is partial.
COMPLETEDEvery directory has an answer. results and summary are final.
FAILEDThe scan could not be started, for example because the service was temporarily unavailable (SY83010). failure has the code and message, and results is empty. A failed scan does not use your allowance, so you can create it again.

A scan answered from a recent scan or from one of your own locations is COMPLETED the moment it is created, so a single GET is enough.

3. Read the results

A completed scan has a summary and one entry in results for each directory checked, up to about 60 depending on the country. business returns the input you sent, with phone normalized to international format.

JSON
{  "total": 62,  "found": 21,  "accurate": 18,  "inaccurate": 2,  "notFound": 41,  "pending": 0,  "unavailable": 0}

total is the number of directories. found is how many list the business, and it splits into accurate, inaccurate, and any presence-only listings described below. notFound is how many do not list it. unavailable counts directories that could not be checked this time, and pending is non-zero only while the scan runs.

Each result has a status for the directory:

Result statusMeaning
FOUNDThe directory lists the business. listing holds what it shows.
NOT_FOUNDThe directory was checked and the business is not there.
UNAVAILABLEThe directory could not be checked this time. This is not the same as not listed, so do not report it as a gap. Check that directory again later.
PENDINGNot checked yet. Only seen while the scan is running.

For a FOUND result, accurate says whether the listing matches your input:

  • true means name, address and phone all match.
  • false means at least one does not. A field the listing leaves out counts against it, so a listing with no phone number is false.
  • null means the business was found but there was no field-by-field check.

accuracy explains the verdict with a status and a message for each of name, address and phone. The status is ACCURATE, INACCURATE, or MISSING, and message is null when the field is accurate. Here is a Facebook page that matches on name and address but has no phone number:

JSON
{  "directory": {    "id": 184,    "name": "Facebook",    "url": "facebook.com"  },  "status": "FOUND",  "accurate": false,  "accuracySource": "DIRECTORY_MATCH",  "accuracy": {    "name": {      "status": "ACCURATE",      "message": null    },    "address": {      "status": "ACCURATE",      "message": null    },    "phone": {      "status": "MISSING",      "message": "No phone number on the listing"    }  },  "listing": {    "name": "Joe's Pizza",    "street": "7 Carmine St",    "city": "New York",    "state": "NY",    "postalCode": "10014",    "country": "US",    "fullAddress": "7 Carmine St, New York, NY 10014",    "phones": [],    "url": "https://www.facebook.com/177353602413952"  },  "syncStatus": null}

Some directories only confirm that the business exists and do not show enough detail to compare. These come back FOUND with accurate: null, and they are counted in found only, not in accurate or inaccurate. Treat them as present, not as verified:

JSON
{  "directory": {    "id": 374,    "name": "Map Quest",    "url": "www.mapquest.com"  },  "status": "FOUND",  "accurate": null,  "accuracySource": "DIRECTORY_MATCH",  "accuracy": {    "name": {      "status": "ACCURATE",      "message": null    },    "address": {      "status": "MISSING",      "message": "Not shown"    },    "phone": {      "status": "MISSING",      "message": "Not shown"    }  },  "listing": {    "name": "Joe's Pizza",    "street": "7 Carmine St",    "city": "New York",    "state": "NY",    "postalCode": "10014",    "country": null,    "fullAddress": "7 Carmine St, New York, NY 10014, US",    "phones": [],    "url": null  },  "syncStatus": null}

A NOT_FOUND result has no listing and no accuracy:

JSON
{  "directory": {    "id": 352,    "name": "YellowPages",    "url": "www.yellowpages.com"  },  "status": "NOT_FOUND",  "accurate": null,  "accuracySource": "DIRECTORY_MATCH",  "accuracy": null,  "listing": null,  "syncStatus": null}

Several directories do not hold their own data. Uber, Waze and Lyft show Google Maps data, and Copilot shows Bing. Their results mirror the source directory, so they usually repeat what Google Maps or Bing returned, and they have no listing url. Count them as separate entries in summary, but do not read them as independent confirmation:

JSON
{  "directory": {    "id": 350,    "name": "Uber",    "url": "uber.com"  },  "status": "FOUND",  "accurate": true,  "accuracySource": "DIRECTORY_MATCH",  "accuracy": {    "name": {      "status": "ACCURATE",      "message": null    },    "address": {      "status": "ACCURATE",      "message": null    },    "phone": {      "status": "ACCURATE",      "message": null    }  },  "listing": {    "name": "Joe's Pizza",    "street": "7 Carmine Street",    "city": "New York",    "state": "NY",    "postalCode": "10014",    "country": "US",    "fullAddress": "7 Carmine St, New York, NY 10014, USA",    "phones": [      "+1 212-366-1182"    ],    "url": null  },  "syncStatus": null}

AI assistants such as ChatGPT and Gemini are included too. For them, listing shows what the assistant knows about the business, which is often just a name, a full address and a phone number, and there is no per-field accuracy.

To turn a completed scan into a fix list, print every directory with a wrong or missing field:

Directories that need attention
curl -s https://listingsapi.com/api/v4/listings/scans/TGlzdGluZ1NjYW46Mzk0YTRhNWMtZjcwMy00NjZiLTg2NjAtMDU4MTlmNDhkZjMz \-H "Authorization: API $LISTINGSAPI_KEY" \| jq -r '.data.listingScan.results[]         | select(.accurate == false)         | .directory.name as $directory         | .accuracy         | to_entries[]         | select(.value.status != "ACCURATE")         | $directory + " " + .key + ": " + (.value.message // .value.status)'

For the Joe's Pizza scan this prints two lines: Facebook has no phone number, and Judys Book shows a different one.

Text
Facebook phone: No phone number on the listingJudys Book phone: Phone number does not match

Where the results come from

source tells you how the scan was answered, and resultsAsOf is when the data was collected.

sourceMeaning
DIRECTORY_SEARCHA fresh search of the directories. resultsAsOf is when this scan finished.
RECENT_SCANA copy of an identical business scanned, by anyone, in the last seven days. It completes immediately, and resultsAsOf is when the original data was collected, so it can be older than createdAt.
LOCATION_LISTINGSThe business matched one of your own locations by phone, postal code and country, so the results come from that location's real listings. locationId is set, accuracySource is SYNC_STATUS instead of DIRECTORY_MATCH, and each result includes that listing's syncStatus.

Costs, limits and errors

Only a fresh DIRECTORY_SEARCH scan uses your monthly allowance, which depends on your plan and resets with your billing period. These are free:

  • repeating a scan of the same business within seven days. Your own earlier scan is returned with reused: true;
  • a scan that a recent scan of the same business, by any account, can answer. It is a RECENT_SCAN copy;
  • scans that match one of your own locations (LOCATION_LISTINGS);
  • scans that end FAILED, which are refunded.

Scans are kept for 90 days. After that GET returns 404 with SY83005.

The create and list responses include a quota object, so you can see your position before you start a batch. List returns your scans newest first, in pages of 1 to 50 with perPage, without results.

Check your allowance
curl -s "https://listingsapi.com/api/v4/listings/scans?page=1&perPage=20" \-H "Authorization: API $LISTINGSAPI_KEY" \| jq '.data.listingScans.quota'
quota fieldMeaning
limitFresh scans your plan allows in the current period.
usedFresh scans used so far.
remaininglimit minus used, never below zero.
resetsAtWhen the allowance resets.

These are the errors you will see most often. Create returns its errors in the body with HTTP 200; get and list return the usual HTTP status.

CodeWhereMeaning and fix
SY83002CreateThe monthly allowance is used up. The message includes the limit and the reset time, and quota is returned. Wait for resetsAt, or reuse a scan from the last seven days, which costs nothing.
SY83003Create, or a scan's failureA value is missing or invalid. contextInfo names the field. Fix the input and send it again.
SY83004CreateListings scans are not available for that countryIso.
SY83001CreateYour plan does not include listings scans.
SY83005GetNo scan with that id, or the scan belongs to another account or is older than 90 days. HTTP 404.
SY83010Create, or a scan's failureScans are temporarily unavailable. Try again later.
SY90016CreateThe key is read-only. Use a Write key.

A GET for an id that does not exist returns:

JSON
{  "data": {    "listingScan": null  },  "errors": [    {      "message": "SY83005: Listing scan not found"    }  ]}

A scan that ends FAILED has its reason in failure, and results stays empty:

JSON
{  "status": "FAILED",  "failure": {    "code": "SY83010",    "message": "SY83010: Listing scans are temporarily unavailable, try again later",    "field": null  }}

The general error format is in Error codes.

For AI agents

The hosted MCP server exposes this feature as three tools: listings_scans_create, listings_scans_get and listings_scans_list. An assistant can start a scan for a business the user names, poll it with listings_scans_get, and summarize the directories that need attention. As with REST, creating a scan needs a Write key. Setup is in Connect AI agents to listingsAPI MCP.

Next steps