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.
| key | meaning |
|---|---|
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_review | Unresolved 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_present | How 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
| decision | meaning |
|---|---|
match | The evidence supports the same person. |
non_match | Something 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
| result | meaning |
|---|---|
exact_match | Identical after normalization. |
strong_match | Same value, written differently. |
partial_match | Consistent, but only partly comparable. |
weak_match | Agreement that many unrelated people would share. |
no_evidence | Nothing to learn from this field. Missing data is never treated as a mismatch. |
conflict | Disagreement that has innocent explanations. |
strong_conflict | Disagreement 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" } }
| status | code | when |
|---|---|---|
| 400 | invalid_request | A record has no identity field, or an unsupported field was sent. |
| 401 | unauthorized | Missing or unrecognised API key. |
| 400 | invalid_request | A batch is empty, over the 5,000-record cap, or has duplicate ids. |
| 402 | payment_required | Credits exhausted. A batch is charged or rejected whole — never partly processed. |
| 429 | rate_limited | Too many requests. |
| 500 | internal_error | Our 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.