sameornot which records in your list are the same person?

API

Two endpoints. Compare two person records, or send a whole list and get back the merges we are confident about plus a ranked queue of the ones a human should look at. Median response time for a single comparison is a few milliseconds; nothing you send is retained.

Authentication

Small requests need no key at all. Up to 100 records per batch (and any pairwise comparison), rate-limited by IP — enough to try the engine from curl, a notebook, or an agent without stopping to create an account.

Past that, sign up for a key — instant, free, no card. Keys look like sameornot_…, are shown once when created, and come with 1,000 free credits. Pass the key as a bearer token.

Authorization: Bearer sameornot_xxxxxxxxxxxx

Fields

Five matched fields: name, email, phone, date_of_birth, address. Each is optional individually, but every record must carry at least one other than address — an address identifies a dwelling, and everyone who ever lived there shares it. Send everything you have: more independent evidence, more reliable result.

Address is positive-only evidence. Two records agreeing on one is worth real weight; two records disagreeing costs nothing at all, because people move and home and work are both theirs. It is also deliberately worth less than a matching phone — a household shares an address and does not share a mobile — so name plus address lands at moderate, and a common name plus a shared address still returns unknown.

POST /v1/person/match

Compare two records.

curl

curl -sS https://www.sameornot.com/v1/person/match \
  -H "Authorization: Bearer $SAMEORNOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "left":  {"name": "Bob Smith",    "email": "bob@acme.com",
              "phone": "+1 402-555-1234", "date_of_birth": "1980-04-12"},
    "right": {"name": "Robert Smith", "email": "rsmith@acme.com",
              "phone": "4025551234",     "date_of_birth": "1980-04-12"}
  }'

Python

import os, requests

response = requests.post(
    "https://www.sameornot.com/v1/person/match",
    headers={"Authorization": f"Bearer {os.environ['SAMEORNOT_KEY']}"},
    json={
        "left":  {"name": "Bob Smith", "phone": "+1 402-555-1234"},
        "right": {"name": "Robert Smith", "phone": "4025551234"},
    },
    timeout=10,
)
result = response.json()
print(result["decision"], result["confidence_band"])
for item in result["evidence"]:
    print(" -", item["field"], item["explanation"])

Response

{
  "decision": "match",
  "confidence": 0.9,
  "confidence_band": "strong",
  "summary": "Strong evidence indicates these records represent the same person.",
  "evidence": [
    {"field": "name", "result": "strong_match",
     "signals": ["nickname_variant", "surname_exact"],
     "explanation": "Bob is a common nickname for Robert. Surnames match exactly (Smith)."},
    {"field": "phone", "result": "exact_match",
     "signals": ["normalized_phone_exact"],
     "explanation": "Phone numbers normalize to the same number (+14025551234)."}
  ],
  "conflicts": [],
  "ignored_fields": [],
  "engine": {"version": "person-v1.0"},
  "request_id": "req_…"
}

POST /v1/person/batch

Send up to 5,000 records and get back three answers: the records we merged, the records we are confident are different people, and a ranked queue of the pairs a human should settle. This is synchronous: there is no job to poll, no results stored on our side, and nothing to delete afterwards. Split larger lists and send them in parts.

You supply an id per record and get the same ids back. We never invent identifiers, because we keep nothing to attach them to. Omit them and we use the array position.

curl -sS https://www.sameornot.com/v1/person/batch \
  -H "Authorization: Bearer $SAMEORNOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"records": [
        {"id": "1", "name": "Bob Smith",            "phone": "402-555-1234"},
        {"id": "7", "name": "Robert Smith",         "phone": "+1 (402) 555-1234"},
        {"id": "3", "name": "Emmeline Ravensworth", "address": "5 Oak Ave"},
        {"id": "9", "name": "Emmeline Ravensworth", "address": "5 Oak Ave"},
        {"id": "5", "name": "Alan Whitfield",       "date_of_birth": "1962-03-04"},
        {"id": "6", "name": "Alan Whitfield",       "date_of_birth": "1991-08-19"}
      ]}'

Response

{
  "groups": [
    {"record_ids": ["1", "7"], "confidence_band": "moderate",
     "evidence": [{"record_ids": ["1", "7"], "confidence": 0.64,
                   "confidence_band": "moderate", "summary": "…", "fields": [ … ]}]}
  ],
  "distinct": [
    {"record_ids": ["5", "6"], "confidence": 0.1, "confidence_band": "conflicting",
     "reason": "These records represent different people: the date of birth conflicts,
                which outweighs the matching evidence.",
     "evidence": [ … ]}
  ],
  "review_queue": [
    {"record_ids": ["3", "9"], "confidence": 0.55, "confidence_band": "insufficient",
     "reason": "Only the name can be compared, and a name alone does not identify a
                person — send an email, phone or date of birth for a decision.",
     "evidence": [ … ]}
  ],
  "ignored_fields": [],
  "stats": {"records": 6, "candidate_pairs": 3, "groups": 1, "needs_review": 1, … },
  "engine": {"version": "person-v1.0"},
  "request_id": "req_…"
}

Groups are the transitive closure over match decisions only — if A matches B and B matches C, all three are one group, and the band shown is the weakest merge holding it together. Nothing merges on an unknown.

distinct is the pairs we compared and are confident are different people, most-similar-first. Without it a pair that survives unmerged is ambiguous — you cannot tell a decision from a pair we never compared. The top of this list is where a wrong split would be, so it is the part worth reading.

The review queue is the unknown pairs worth a person's time, ranked by confidence, with the evidence that was and was not available. This is the part most engines cannot give you: they return a score and leave you to pick a threshold. At most 500 rows come back; stats.needs_review is always the true count.

Pairs below stats.review_queue_min_confidence (0.3) are held back rather than returned, and counted in stats.review_queue_below_floor. A queue is a worklist, not a log: two records sharing only a common name have nothing for a reviewer to weigh, and a queue that opens with them is a queue reviewers stop reading. Nothing is dropped silently — needs_review minus the floor count is always the queue's length.

stats

Counts only — populated fields, never values.

keymeaning
records, candidate_pairs Records received, and pairs actually compared. We compare records that share an exact normalized key (a phone, an email, a surname) rather than all n² pairs.
groups, grouped_records Groups found, and how many records ended up in one.
needs_reviewUnresolved pairs — the true total, including any held back by the floor.
review_queue_below_floor, review_queue_min_confidence Unresolved pairs too weak to be worth reviewing, and the floor applied.
unresolved_for_missing_fields Unresolved pairs where one record had a field the other lacked. Usually the cheapest accuracy win available to you.
unresolved_with_address_on_both_sides Unresolved pairs where both records had an address. Now that address is matched, these are the pairs whose addresses genuinely differ.
fields_presentHow many records carried each field.
blocking Reports any limit that was hit: blocks too large to compare (oversized_blocks) and whether the comparison cap was reached (candidate_pairs_truncated). Both are normally zero and false.

Decisions

decisionmeaning
matchThe evidence supports the same person.
non_matchSomething positively contradicts a match.
unknown Not enough distinguishing evidence to decide. This is a feature — we would rather abstain than guess.

Confidence

The field is called confidence, not probability, on purpose. It is the engine’s strength of belief that the two records describe the same person, produced by an explicit rule scheme — not a calibrated statistic. Prefer the qualitative confidence_band when showing anything to a human: strong, moderate, insufficient, conflicting.

Per-field results

resultmeaning
exact_matchIdentical after normalization.
strong_matchSame value, written differently.
partial_matchConsistent, but only partly comparable.
weak_matchAgreement that many unrelated people would share.
no_evidenceNothing to learn from this field. Missing data is never treated as a mismatch.
conflictDisagreement that has innocent explanations.
strong_conflictDisagreement that rules out a match.

Errors

{ "error": { "code": "invalid_request",
             "message": "Each record must contain at least one identity field.",
             "request_id": "req_…",
             "feedback": "casey@sameornot.com" } }
statuscodewhen
400invalid_requestA record has no identity field, or an unsupported field was sent.
401unauthorizedMissing or unrecognised API key.
400invalid_requestA batch is empty, over the 5,000-record cap, or has duplicate ids.
402payment_requiredCredits exhausted. A batch is charged or rejected whole — never partly processed.
429rate_limitedToo many requests.
500internal_errorOur fault. Quote the request_id.

Usage and credits

curl -sS https://www.sameornot.com/v1/usage -H "Authorization: Bearer $SAMEORNOT_KEY"

One unit: credits. A pairwise comparison costs 1. A batch costs 1 per input record — not per comparison, which would be millions for a list of any size. Free tier is 1,000 records. Validation errors and requests rejected for insufficient credits are not charged.

More credits are prepaid packs via Stripe — buy in the browser, or from code:

curl -sS -X POST https://www.sameornot.com/v1/billing/checkout \
  -H "Authorization: Bearer $SAMEORNOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"pack": "starter"}'
# → {"url": "https://checkout.stripe.com/…"}  — open it to pay; credits land on
#   your key as soon as Stripe confirms the payment.

Something wrong?

If the engine got a comparison wrong, send it to casey@sameornot.com — made-up equivalents are fine and preferred. Interesting cases become permanent tests in our benchmark corpus.