Match people, normalize addresses, and enrich records.
Use Linksync to compare person records with Splink, parse messy US addresses, match against a verified OpenAddresses database, and return locality-aware place details.
Quick start
curl -X POST https://your-domain.example/api/normalize \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"address":"47 Glen Avenue, Newton, MA 02459"}'
The public demo uses /api/normalize/demo. Production API requests should use a bearer token from /oauth/token with client credentials.
Overview
Match people
Compare CRM records, imports, or caller-supplied candidate blocks using Splink.
Parse
Convert free-text or structured inputs into consistent address components.
Match
Optionally resolve addresses against OpenAddresses records with coordinates and IDs.
Enrich
Return main city, locality, state, and administrative area details where available.
Authentication
Request an OAuth-style bearer token, then send it as Authorization: Bearer <access_token> on API calls. Legacy ?key=... access still works for now but bearer auth is preferred.
Client credentials grant
grant_type=client_credentials&client_id=YOUR_API_KEY&client_secret=YOUR_OAUTH_CLIENT_SECRET
Response
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600
}
Normalize a free-text address
Send a single address string. Linksync parses it into address components. Add match=true to also attempt a verified building or place match against Linksync's OpenAddresses database.
- address
- Required. Full address string for POST body or GET query string.
- match
- Optional query parameter. Boolean, defaults to
false. Attempts an OpenAddresses building match (with a limited legacy Photon fallback) and addsbest_result/matchedto the response. - alternative_candidates
- Optional query parameter, only relevant when
match=true. Integer0-10, defaults to1. Caps how many other plausible matches can appear incandidateswhen the top pick was ambiguous.0suppressescandidatesentirely. See Confidence & candidates. - locality_output_mode
- Optional request field. One of
main_city,replace_city, orseparate_field.
POST request
{
"address": "47 Glen Avenue, Newton, MA 02459",
"locality_output_mode": "separate_field"
}
Example response (match=true)
{
"input": "47 Glen Avenue, Newton, MA 02459",
"components": {
"house_number": "47",
"road": "glen avenue",
"city": "newton",
"state": "ma",
"postcode": "02459"
},
"normalized_components": {
"housenumber": "47",
"street": "glen avenue",
"city": "newton",
"state": "ma",
"postcode": "02459"
},
"best_result": {
"street": "Glen Avenue",
"housenumber": "47",
"unit": "",
"postcode": "02459",
"city": "Newton",
"locality": "Newton Centre",
"admin_area": "Middlesex County",
"admin_area_type": "county",
"state": "MA",
"country_code": "US",
"lat": 42.298,
"lon": -71.203,
"place_id": "addr:29751734",
"resolution_status": "LOCAL_EXACT_MATCH",
"confidence": 100
},
"matched": true
}
components is libpostal's raw parse, verbatim, and keeps this exact shape/meaning. normalized_components is what was actually matched against: housenumber/street/unit/city/state/postcode after Linksync corrects a couple of known libpostal quirks on free text with no state given (a house_number/unit swap, or a city folded into the street) -- see components for comparison if the two ever disagree. Only present when match=true.
When match=false (the default), the response is just {"input": ..., "components": ..., "matched": false} — no lookup is attempted.
Normalize a structured address
Use this endpoint when your source system already separates street, city, state, and postcode fields.
- housenumber
- Required with
street. Street number, including unit suffix if present. - street
- Required with
housenumber. Street name. - city/state/postcode
- At least one of these should be supplied. Postcode lookup can fill missing city/state details.
- countrycode
- Optional. Defaults to the configured country, currently US.
- match
- Optional query parameter. Boolean, defaults to
false. Same behavior as the free-text endpoint. - alternative_candidates
- Optional query parameter, only relevant when
match=true. Integer0-10, defaults to1. See Confidence & candidates.
POST request
{
"housenumber": "47",
"street": "Glen Avenue",
"city": "Newton",
"county": "Middlesex",
"state": "MA",
"postcode": "02459",
"countrycode": "US"
}
Response shape (input/components/best_result/matched/candidates) is identical to the free-text endpoint above.
Live address autocomplete
Typeahead suggestions for a UI to call on every debounced keystroke as an operator types a street/house number. Deliberately cheap — no libpostal call unless you pass a single free-text address box, and no Photon fallback.
- street / housenumber
- Preferred, structured calling convention. Pass whichever has been typed so far.
- address
- Alternative unstructured calling convention for a single free-text box (e.g.
"40 Main St 0245"), parsed via libpostal. Ignored oncestreet/housenumberare supplied. Because libpostal is trained on complete addresses, results on a genuinely partial string can be inconsistent — prefer separate fields when your UI can offer them. - state
- 2-letter abbreviation or full state name. Required (see below) so the query always has an indexed column to scope on.
- postcode / city
- At least one required (see below). If only
postcodeis supplied, Linksync resolvesstate(andcity) from it automatically — the "zip first" workflow. - limit
- Optional. Integer
1-15, defaults to8.
state and one of postcode/city are required, so every lookup has an indexed column to narrow on rather than scanning a whole state's worth of addresses. Accounts with the unscoped_autocomplete premium flag can omit all three and search on a bare street/house-number — Linksync falls back all the way to a nationwide scan. Without that flag, an unscoped request gets a 400 explaining it's a premium feature; ask an admin to enable it for your account. The public demo endpoints (/demo suffix) always allow unscoped search.
Example request/response
GET /api/normalize/autocomplete?state=MA&postcode=02459&housenumber=47&street=Glen
{
"state": "MA",
"city": "Newton",
"postcode": "02459",
"suggestions": [
{
"housenumber": "47",
"street": "Glen Avenue",
"unit": "",
"city": "Newton",
"state": "MA",
"postcode": "02459",
"place_id": "addr:29751734"
}
]
}
Cross-state postcodes
A handful of ZIP codes genuinely span more than one state (e.g. 42223 covers both Fort Campbell, KY and TN). If you supply only a postcode that resolves ambiguously, Linksync won't guess — it returns ambiguous_state: true and a possible_states list instead of suggestions, so your UI can ask the operator to pick one and retry with an explicit state:
{
"ambiguous_state": true,
"possible_states": ["KY", "TN"],
"postcode": "42223",
"city": "Fort Campbell",
"suggestions": []
}
Field-level city/postcode suggestions
Separate, cheaper endpoints for suggesting just one field's value from Linksync's reference data — useful before the operator has started typing a street at all.
| Endpoint | Required params |
|---|---|
GET /api/normalize/autocomplete/city | state, prefix |
GET /api/normalize/autocomplete/postcode | state, prefix (optional city to narrow further) |
GET /api/normalize/autocomplete/city?state=MA&prefix=New
{
"state": "MA",
"suggestions": [
{ "city": "Newton", "state": "MA" }
]
}
Direct address lookup by place_id
Re-fetches the exact record behind a place_id your app already saw in an earlier autocomplete, normalize, or structured normalize response (currently always "addr:" + <id>).
Prefer this over resubmitting an autocomplete suggestion's fields through /api/normalize/structured: re-matching by housenumber/street/city/etc. re-runs the full validation pipeline, including postcode-based locality canonicalization (e.g. an input city of "Auburndale" or "Newton Centre" gets normalized toward its ZIP's main city, "Newton", when no exact address is tagged with that name) -- which can resolve to a different address_candidates row than the one actually shown/picked. A direct-by-id lookup can only ever return that exact row, or nothing.
- place_id
- Required. A
place_idvalue from a prior response, e.g.addr:4794479.
Example request/response
GET /api/normalize/place?place_id=addr:4794479
{
"place_id": "addr:4794479",
"found": true,
"result": {
"street": "GLEN AVE",
"housenumber": "47",
"unit": "",
"postcode": "02459",
"city": "NEWTON CENTRE",
"state": "MA",
"country_code": "US",
"lat": 42.32,
"lon": -71.19,
"place_id": "addr:4794479",
"resolution_status": "LOCAL_DIRECT_LOOKUP",
"confidence": 100
}
}
Returns {"place_id": ..., "found": false, "result": null} (still 200) if the id no longer exists. The public demo uses /api/normalize/place/demo.
Confidence scoring & multiple candidates
Every best_result (when match=true) carries two extra fields:
- resolution_status
- Which validation tier produced the match. Local
address_candidatestiers (which aggregate OpenAddresses, MassGIS, NAD, and other imported sources) start withLOCAL_; legacy statuses such asSTANDARD_ZIP_MATCHcome from the temporary Photon fallback used only for addresses those tiers haven't resolved yet. - confidence
- A 0-100 heuristic score derived from
resolution_status— a ranking, not a calibrated probability. OpenAddresses tiers always outrank the equivalent Photon fallback tier. Always inspectresolution_statusfor the underlying reason.
Even an unresolved response (matched: false, resolution_status: "UNRESOLVED_MISMATCH") can carry a low single-digit confidence (1-9) for a genuine "near-miss" — same housenumber and as many of city/state/postcode as agree, but the street name itself never verified.
Returning multiple candidates
When the top pick was genuinely ambiguous — most commonly a building with several units on file where the input either didn't specify one, or specified one that isn't on file — the response also includes a candidates array of other plausible matches, shaped identically to best_result:
{
"best_result": {
"street": "Beaconwood Rd",
"housenumber": "40",
"unit": "",
"resolution_status": "LOCAL_UNIT_UNSPECIFIED",
"confidence": 85
},
"candidates": [
{
"street": "Beaconwood Rd",
"housenumber": "40",
"unit": "A",
"resolution_status": "LOCAL_UNIT_UNSPECIFIED",
"confidence": 85
}
],
"matched": true
}
Use the alternative_candidates query parameter (0-10, default 1) to control how many can appear; 0 disables the array. It's simply absent for confident matches (e.g. LOCAL_EXACT_MATCH) and for results resolved via the Photon fallback, which doesn't carry alternates. Batch CSV exports carry the same data in a derived_alternative_candidates column (a JSON-encoded array).
"Did you mean" suggestions
When neither OpenAddresses nor the Photon fallback can verify an address at all, Linksync tries one more relaxed, house-number-agnostic lookup on a trigram-matched street. If that finds plausible guesses, the response still has matched: false, but best_result.resolution_status is LOCAL_SUGGESTED and candidates may list further alternatives. Treat these as suggestions to offer an operator, never as verified data to save as-is.
Linksync also won't even attempt the Photon fallback when a request has neither a state nor a city (not even one resolved from a postcode) — Photon's free-text geocoder has nothing to disambiguate on in that case, so it would otherwise just return whichever of many identically-named streets nationwide it happens to rank first. That case surfaces as resolution_status: "PHOTON_SKIPPED_NO_LOCALITY" (still matched: false) before Linksync tries the did-you-mean lookup above. Include a city, state, or postcode in your input to avoid it.
States & postcodes reference data
Static/DB-backed reference data for building your own state, city, or postcode inputs against the exact names Linksync expects back in state params — no need to hand-roll (and risk drifting from) your own copy.
Returns every US state (plus DC) with its 2-letter abbreviation and full name. No request params.
{
"states": [
{ "abbreviation": "AL", "name": "Alabama" },
{ "abbreviation": "MA", "name": "Massachusetts" }
]
}
Returns the state(s) a given postcode (required) is known to cover — almost always exactly one, but more than one for a genuine cross-state ZIP. Useful for scoping a "zip first" UI's own state picker before the operator has typed a street at all.
GET /api/normalize/postcode/states?postcode=42223
{
"postcode": "42223",
"states": [
{ "abbreviation": "KY", "name": "Kentucky" },
{ "abbreviation": "TN", "name": "Tennessee" }
]
}
Person matching
Person matching requires Match People to be enabled on your account. Use bearer auth from /oauth/token.
Match an uploaded person file
After uploading and processing a person-matching CSV in the UI or upload API, call Splink match with the upload id. Linksync automatically uses your primary person parquet/model.
{
"upload_id": 456,
"matching_scheme": "person_balanced_v1",
"use_trained_model": true
}
Match direct JSON records
Use this endpoint when you want to send people directly instead of uploading a CSV. Linksync builds temporary Splink input parquet and returns an upload_id that can be used with progress and download endpoints.
{
"blocking_mode": "splink",
"matching_scheme": "person_default_v1",
"use_trained_model": true,
"parse_address": true,
"match_address": true,
"records": [
{
"row_id": "import-55",
"full_name": "Simon French",
"email": "simon@example.com",
"address": "47 Glen Avenue, Newton, MA 02459"
}
]
}
- matching_scheme
- Optional. One of
person_default_v1,person_conservative_v1,person_balanced_v1,person_import_v1,household_v1, ororganization_v1. - use_trained_model
- Optional, defaults to
true. Set tofalseto ignore the stored trained model and use the selected scheme settings. - blocking_mode
- Optional for direct records.
splinkuses Linksync/Splink blocking.crmuses caller-supplied block ids. - parse_address
- Optional. Parse single-line addresses with libpostal before creating Splink input.
- match_address
- Optional. Match parsed or structured addresses against Linksync's OpenAddresses database and use verified, matched address fields.
Accepted person and address fields
Records may include full_name or first_name plus last_name, and optional fields such as middle_name, dob, gender, email, phone, organization_name, and person_id.
Address input may be a single string in address, address_line, or full_address; an address object; or top-level components such as street, housenumber, city, state, and postcode. Request-level parse_address and match_address can also be overridden per record.
CRM-supplied blocking
When your CRM has already selected candidate blocks, send blocking_mode: "crm". Linksync only compares records where primary_records[].crm_block_id equals records[].crm_block_id.
{
"blocking_mode": "crm",
"matching_scheme": "person_default_v1",
"use_trained_model": true,
"parse_address": true,
"match_address": true,
"primary_records": [
{
"row_id": "crm-1001",
"crm_block_id": "block-a",
"full_name": "Simon French",
"address_line": "47 Glen Avenue, Newton, MA 02459"
}
],
"records": [
{
"row_id": "import-55",
"crm_block_id": "block-a",
"first_name": "Simon",
"last_name": "French",
"address": {
"street": "Glen Avenue",
"housenumber": "47",
"postcode": "02459"
}
}
]
}
primary_records and records. Direct JSON matching is intended for small/interactive jobs; use the CSV upload workflow for larger files.
Remove PII but keep training analysis
Use this endpoint when a user wants uploaded person/address data removed while keeping the Splink trained model summary and training analysis charts.
Linksync deletes source files, result files, Splink input CSV/parquet files, and Splink match outputs. It keeps the trained model JSON and training chart HTML files, so training analysis remains available.
Locality output modes
Locality output lets clients choose whether ZIP-level neighborhoods and villages should appear as the returned city or as separate fields.
| Mode | Behavior |
|---|---|
main_city | Return the canonical main city only. |
replace_city | Replace city with a nearby locality when Linksync can do so safely. |
separate_field | Keep city as the main city and add locality, admin_area, and admin_area_type when available. |
Errors and geographic limits
- 400: missing or invalid request input — including an unscoped autocomplete request (no state/postcode/city) on an account without the
unscoped_autocompletepremium flag. - 401: missing, expired, or invalid bearer token.
- 500: unexpected server error, returned as
{"error":"message"}.
Results are filtered to the configured country, currently US. Some deployments may also be restricted to a test state such as Massachusetts.