Skip to main content
Thirdwatchthirdwatch
Business & local data

Enrich CRM Records with Verified Apple Maps Place Data

Fill missing phone, website, address and coordinates on CRM accounts from Apple Maps, with a confidence-scored match and a safe write-back pattern in Python.

Sep 8, 2026 · 6 min read · 1,492 words
See the scraper →

Thirdwatch's Apple Maps Scraper fills the gaps in local-business CRM records: phone in E.164 form, website, a postal address already split into street, city, state and postcode, GPS coordinates, IANA timezone, category and a rating with its source. Query a company name plus its city, score the candidates, and write back only what you are confident about. Built for revenue operations, data stewards and anyone maintaining an account table full of local businesses.

Why enrich CRM records with map data

Local-business CRM data decays faster than almost any other kind, because the underlying businesses churn. The Census Bureau's Business Formation Statistics show hundreds of thousands of new US business applications filed every month, and the same churn that creates them closes, relocates and rebrands others. Meanwhile the fields a rep actually needs — a dialable phone, a live website, a postcode good enough to route a territory — are exactly the ones a hand-typed record gets wrong first.

The job-to-be-done is repair, not acquisition. An ops lead inherits 12,000 account records where 40% have no phone and a third of the addresses are missing a postcode. A field-sales team needs coordinates and timezones on every account so territory assignment and call windows stop being guesswork. A marketing team needs a category on each account before it can segment anything. None of those need a new lead source; they need the accounts already in the system matched against a current, structured place record. Apple's place card answers all of it in one call, and because Apple listings are maintained by owners through Apple Business Connect, the contact details on active businesses tend to be current.

How does this compare to the alternatives?

Enrichment vendors are priced for firmographics. Local place data is a different problem.

Approach Cost model Reliability Setup time Maintenance
Manual verification by an SDR or VA Hourly labour Accurate but slow and unrepeatable Immediate, never finishes The whole file rots again next quarter
B2B enrichment platform Seat or credit subscription Strong on firmographics, thin on storefront detail Days of procurement and mapping Vendor coverage gaps are invisible to you
Thirdwatch Apple Maps Scraper Pay per place returned Structured place cards, stable field names Fifteen minutes Thirdwatch tracks Apple-side changes

Enrichment platforms are good at headcount, industry codes and funding. They are frequently weak on the storefront layer — the real phone number, the current opening hours, the coordinates of the actual door. The Apple Maps Scraper actor page covers that layer directly, and you keep full control of the matching logic.

How to enrich a CRM from Apple Maps in 5 steps

Step 1: How do I prepare the account list?

Authenticate with a token from Apify Settings then Integrations, after signing up at apify.com.

export APIFY_TOKEN="apify_api_xxxxxxxxxxxxxxxx"

Export the accounts you want to repair, keeping only what you need to build a query and to score a match.

import os, re, requests, unicodedata, pandas as pd

ACTOR = "thirdwatch~apple-maps-scraper"
TOKEN = os.environ["APIFY_TOKEN"]

accounts = pd.read_csv("crm_accounts.csv")  # id, name, city, region, phone, postal_code
accounts["query"] = accounts.name.str.strip() + " " + accounts.city.str.strip()

A company name plus its city is the query Apple parses best. Adding a street address rarely helps and often narrows the search to nothing.

Step 2: How do I fetch candidates in batches?

queries takes up to 100 entries per run, so batch the file and keep maxResults low — you want the top few candidates per name, not a category sweep.

def fetch(batch_queries, country_code="US", language="en-US"):
    r = requests.post(
        f"https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items",
        params={"token": TOKEN},
        json={
            "queries": batch_queries,
            "maxResults": 5,
            "language": language,
            "countryCode": country_code,
            "includePhotos": False,
            "includeReviews": False,
        },
        timeout=1200,
    )
    return pd.DataFrame(r.json())

BATCH = 100
frames = []
for i in range(0, len(accounts), BATCH):
    frames.append(fetch(accounts["query"].iloc[i:i + BATCH].tolist()))
candidates = pd.concat(frames, ignore_index=True)

Every returned record carries the query field that produced it, which is the join key back to the account row without any extra bookkeeping.

Step 3: How do I score a match instead of trusting the first result?

Three independent signals — name, phone and postcode — beat any one of them alone.

from difflib import SequenceMatcher

def norm(s):
    s = unicodedata.normalize("NFKD", str(s or "")).encode("ascii", "ignore").decode()
    s = re.sub(r"\b(the|ltd|llc|inc|co|pvt|limited)\b", "", s.lower())
    return re.sub(r"[^a-z0-9]", "", s)

def digits(s):
    return re.sub(r"\D", "", str(s or ""))[-10:]

joined = accounts.merge(candidates, on="query", suffixes=("_crm", "_apple"))

joined["name_sim"] = [
    SequenceMatcher(None, norm(r.name_crm), norm(r.name_apple)).ratio()
    for r in joined.itertuples()
]
joined["phone_ok"] = [
    bool(digits(r.phone_crm)) and digits(r.phone_crm) == digits(r.phone_apple)
    for r in joined.itertuples()
]
joined["postal_ok"] = (
    joined.postal_code.astype(str).str.strip().str.upper()
    == joined.postalCode.astype(str).str.strip().str.upper()
)

joined["confidence"] = (
    0.6 * joined.name_sim
    + 0.25 * joined.phone_ok.astype(float)
    + 0.15 * joined.postal_ok.astype(float)
).round(3)

best = joined.sort_values("confidence", ascending=False).drop_duplicates("id")

Note the deliberate asymmetry: a phone match is strong evidence, a phone mismatch is weak evidence against, because the CRM value is the one more likely to be stale.

Step 4: How do I write back without destroying good data?

Split the work into a safe fill pass and a reviewed overwrite pass.

FILL_MAP = {
    "phone": "phoneFormatted",
    "website": "website",
    "street": "street",
    "city": "city_apple",
    "state": "stateCode",
    "postal_code": "postalCode",
    "country": "countryCode",
    "latitude": "latitude",
    "longitude": "longitude",
    "timezone": "timezone",
    "industry": "category",
}

auto = best[best.confidence >= 0.85]
review = best[(best.confidence >= 0.65) & (best.confidence < 0.85)]

fills, conflicts = [], []
for row in auto.itertuples():
    for crm_field, apple_field in FILL_MAP.items():
        new = getattr(row, apple_field, None)
        old = getattr(row, crm_field, None)
        if new in (None, "") or (isinstance(new, float) and pd.isna(new)):
            continue
        if old in (None, "") or (isinstance(old, float) and pd.isna(old)):
            fills.append({"id": row.id, "field": crm_field, "value": new})
        elif str(old).strip() != str(new).strip():
            conflicts.append({"id": row.id, "field": crm_field,
                              "current": old, "proposed": new,
                              "confidence": row.confidence})

pd.DataFrame(fills).to_csv("crm_safe_fills.csv", index=False)
pd.DataFrame(conflicts).to_csv("crm_conflicts_for_review.csv", index=False)
review.to_csv("crm_low_confidence_matches.csv", index=False)

The safe-fill file can go straight into a CRM bulk import. The conflict file is a human queue, and it is usually small enough to clear in an afternoon.

Step 5: How do I push the safe fills to the CRM?

Any CRM with a bulk update endpoint takes the fill file directly. The shape below is a generic REST update loop.

import time

CRM_TOKEN = os.environ["CRM_TOKEN"]
updates = pd.DataFrame(fills).pivot(index="id", columns="field", values="value")

for account_id, props in updates.iterrows():
    requests.patch(
        f"https://api.example-crm.com/v3/objects/companies/{account_id}",
        headers={"Authorization": f"Bearer {CRM_TOKEN}"},
        json={"properties": props.dropna().to_dict()},
        timeout=15,
    )
    time.sleep(0.2)
print(f"{len(updates)} accounts enriched")

Store placeId on the account as an external id. Next month's run can then verify a known place directly instead of re-matching from scratch, and a scheduled job on the Apify scheduler turns the whole thing into a standing hygiene process.

Sample output

A single candidate record, trimmed to the fields the enrichment writes back.

[
  {
    "placeId": "I15FF30DE01EC121F",
    "name": "Daydreamer Coffee",
    "category": "Coffee Shop",
    "categories": ["Dining", "Coffee Shop", "Cafe"],
    "placeType": "BUSINESS",
    "phone": "+17404000238",
    "phoneFormatted": "(740) 400-0238",
    "website": "https://www.daydreamer.coffee/",
    "address": "80 Rainey St, Austin, TX 78701, United States",
    "street": "80 Rainey St",
    "neighborhood": "Downtown",
    "city": "Austin",
    "state": "Texas",
    "stateCode": "TX",
    "postalCode": "78701",
    "country": "United States",
    "countryCode": "US",
    "latitude": 30.2592149,
    "longitude": -97.7389039,
    "timezone": "America/Chicago",
    "ratingOutOf5": 4.0,
    "ratingCount": 27,
    "ratingProvider": "Yelp",
    "appleMapsUrl": "https://maps.apple.com/place?place-id=I15FF30DE01EC121F",
    "query": "Daydreamer Coffee Austin",
    "scrapedAt": "2026-09-08T09:14:02Z"
  }
]

query echoes the string that produced the record, so the join back to the account row needs no extra state. phone is E.164 for dialers and integrations while phoneFormatted is what a rep should see on screen. The address arrives pre-split into street, city, stateCode, postalCode and countryCode, which removes the parsing step that breaks on international records. timezone is the IANA identifier, so call-window logic works without a country-to-offset lookup, and scrapedAt gives every enriched field a defensible provenance date.

Common pitfalls

Enrichment goes wrong in four familiar ways. Accepting the first search result silently attaches the wrong place to accounts with generic names — score the match on name, phone and postcode before writing anything. Overwriting reviewed data destroys the work a rep did by hand; fill empties automatically and route conflicts to a queue. Chain confusion is the sharpest edge: twelve outlets share one name, so a name-only match is meaningless for franchises and postcode agreement should be mandatory there. Treating a missing match as a closure creates false churn signals — no result means the query did not resolve, which is a prompt to re-verify, not proof the business is gone.

Thirdwatch's Actor returns the originating query and a stable placeId on every record, which is what makes both the match and the next month's re-verification cheap, and an empty search finishes as a successful run with a status message so an unmatched batch never fails the job.

Related use cases

Frequently asked questions

Which CRM fields can Apple Maps data fill?

Phone in E.164 and display form, website, the full postal address split into street, city, state, postal code and country, latitude and longitude, IANA timezone, primary category, rating with its provider and rating count. That covers most of a standard company record.

How do I match a CRM account to the right Apple Maps place?

Query the account name plus its city, then score candidates on normalised name similarity, matching last-ten phone digits and postal code agreement. Accept high scores automatically, queue mid scores for review and leave low scores untouched rather than guessing.

Can this detect businesses that have closed or moved?

It flags candidates for review rather than proving closure. An account that returned a confident match last quarter and returns nothing this quarter, or returns a materially different address on the same placeId, is exactly the record a human should re-verify before outreach.

Is the enrichment safe to run automatically against production?

Run it in two passes. The first writes only to empty fields, which is almost always safe. The second proposes overwrites to populated fields as a review queue, so a low-confidence match can never silently replace a phone number a rep confirmed by hand.

How often should the enrichment job run?

Monthly suits most B2B pipelines. Contact details on established businesses move slowly, but new openings, rebrands and relocations accumulate steadily, so a monthly pass keeps drift bounded without generating a review queue nobody has time to clear.

Related

Try it yourself

100 free credits, no credit card.

About 30 real searches. Add the MCP to Claude or Cursor in two minutes.