Developer API

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

1

Match people

Compare CRM records, imports, or caller-supplied candidate blocks using Splink.

2

Parse

Convert free-text or structured inputs into consistent address components.

3

Match

Optionally resolve addresses against OpenAddresses records with coordinates and IDs.

4

Enrich

Return main city, locality, state, and administrative area details where available.

POST /oauth/token

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
}
POST GET /api/normalize

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 adds best_result/matched to the response.
alternative_candidates
Optional query parameter, only relevant when match=true. Integer 0-10, defaults to 1. Caps how many other plausible matches can appear in candidates when the top pick was ambiguous. 0 suppresses candidates entirely. See Confidence & candidates.
locality_output_mode
Optional request field. One of main_city, replace_city, or separate_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.

POST GET /api/normalize/structured

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. Integer 0-10, defaults to 1. 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.

GET /api/normalize/autocomplete

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 once street/housenumber are 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 postcode is supplied, Linksync resolves state (and city) from it automatically — the "zip first" workflow.
limit
Optional. Integer 1-15, defaults to 8.
By default a 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.

EndpointRequired params
GET /api/normalize/autocomplete/citystate, prefix
GET /api/normalize/autocomplete/postcodestate, prefix (optional city to narrow further)
GET /api/normalize/autocomplete/city?state=MA&prefix=New

{
  "state": "MA",
  "suggestions": [
    { "city": "Newton", "state": "MA" }
  ]
}
GET /api/normalize/place

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_id value 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_candidates tiers (which aggregate OpenAddresses, MassGIS, NAD, and other imported sources) start with LOCAL_; legacy statuses such as STANDARD_ZIP_MATCH come 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 inspect resolution_status for 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.

GET /api/normalize/states

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" }
  ]
}
GET /api/normalize/postcode/states

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" }
  ]
}
POST /api/splink/match /api/splink/match/records

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, or organization_v1.
use_trained_model
Optional, defaults to true. Set to false to ignore the stored trained model and use the selected scheme settings.
blocking_mode
Optional for direct records. splink uses Linksync/Splink blocking. crm uses 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.

POST /api/splink/match/records

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"
      }
    }
  ]
}
CRM blocking requires both primary_records and records. Direct JSON matching is intended for small/interactive jobs; use the CSV upload workflow for larger files.
POST /api/uploads/{id}/remove-pii

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.

After PII is removed, stored-upload matching against that primary file cannot run because the primary parquet has been deleted. Use direct/API records or re-process source data when matching needs the primary records again.

Locality output modes

Locality output lets clients choose whether ZIP-level neighborhoods and villages should appear as the returned city or as separate fields.

ModeBehavior
main_cityReturn the canonical main city only.
replace_cityReplace city with a nearby locality when Linksync can do so safely.
separate_fieldKeep city as the main city and add locality, admin_area, and admin_area_type when available.
Locality output is distance guarded. Linksync will not return a locality candidate that is implausibly far from the matched address coordinates. For a non-technical explanation, see How locality matching works.

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_autocomplete premium 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.