Evidence about the details, not a verdict about a person.
One POST request takes any subset of an email address, a phone number, an IP address and a postal address, runs each through the product that owns it, and names where the answers agree and where they do not — a country roll-up, eight comparisons that quote both sides, and nine named flags.
There is no score, no risk level, no verdict and no recommendation, and there will not be one. That refusal is the product.
Free credits every month. No credit card required. Each signal bills as its own lookup; the comparison is free.
$ curl https://spaw.co/api/v1/consistency \ -H "Authorization: Bearer sk_live_…" \ -H "Content-Type: application/json" \ -d '{"email": "[email protected]", "phone": "+442079460018", "country": "GB", "ip": "81.2.69.142", "address": {"address": "10 Downing Street, London, SW1A 2AA", "country": "GB"}}' { "success": true, "data": { "signals": { "email": { … }, "phone": { … }, "ip": { … }, "address": { … } }, "consistency": { "signals_compared": ["email", "phone", "ip", "address"], "country_agreement": "differ", "flags": ["ip_country_differs_from_address_country"], "comparisons": [ { "comparison": "phone_address_country", "left": { "signal": "phone", "field": "country", "value": "GB" }, "right": { "signal": "address", "field": "country", "value": "GB" }, "result": "agree", "withheld": null, "flag": null }, { "comparison": "ip_address_country", "left": { "signal": "ip", "field": "country", "value": "DE" }, "right": { "signal": "address", "field": "country", "value": "GB" }, "result": "differ", "flag": "ip_country_differs_from_address_country" }, … ] } }, "meta": { "requested": 4, "processed": 4, "credits_used": 4, "credits_remaining": 996 } }
What this endpoint will not do.
This is the one endpoint whose output is an inference about a person, so the rules it refuses to break matter more than anything it adds. All three are enforced in the code and pinned by tests.
There is no score, and there will not be one
No score, no risk level, no verdict, no recommendation. The flags are the whole output. A difference between two fields is a fact about the fields, not a judgement about a person: people travel, move house, keep a foreign number after emigrating, and browse through a VPN because they would rather not be tracked. Rolling that into one number would hide which fact drove it, and a number that decides about a person is a number you have to be able to explain to that person.
A null withholds; it never disagrees
Where either side of a comparison is null, the result is not_compared with withheld: missing_value. Across every Spaw product a null means “not evaluated” and never “no” — an invalid phone number carries no country, a reserved IP carries no location, an address outside every installed mail-drop list carries no mail-drop answer. Comparing against a null would turn a gap in the data into a finding about a person.
A VPN is not a lie
Where the IP is a VPN exit, a relay or a datacenter egress, every country comparison involving it is withheld as low_confidence — that country belongs to the network operator, not to the person behind it, and reporting a difference there would flag everyone who uses a VPN. If the network itself is what you want to act on, is_anonymous, is_relay and is_datacenter are right there on the IP block.
One request, and every signal’s own full answer.
data.signals carries one entry per product and all five keys are always present. Each block is exactly what that product’s own endpoint returns, field for field — the 27 email fields, the 33 phone fields, the 46 IP fields and the 45 address fields, each with its own sources — so nothing has to be learned twice and nothing is summarised away.
- Send any one of email, phone, ip and address, or any combination
- A key is null when you did not ask for that signal, or when the call did not reach it
- The legs run ip, phone, address, email — a keyed body has no input order
- No new dataset, no network call and no third party: the analysis reads answers already computed
- Running out of credits part-way keeps what was paid for and reports stopped_reason
| consistency | Example | What it tells you |
|---|---|---|
| signals_comparedstring[] | Which of email, phone, ip and address answered and were available to compare. | |
| country_agreementstring | null | The roll-up over the three country comparisons only: differ if any of them differed, agree if at least one agreed and none differed, and null when none of them could be made — never a silent agree. | |
| flagsstring[] | Every flag raised, in comparison order. The seven below, each with its own reference page. | |
| comparisonsobject[] | Every comparison that was possible, including the ones withheld, each with both sides quoted. |
Eight checks, and the fields that decided each one.
Three countries against each other, one distance carried as evidence, and the pairings where two weak signals coinciding says more than either alone. A comparison is emitted only when both of its signals answered — a signal nobody asked about produces silence, not a row.
| Comparison | Fields | What it says |
|---|---|---|
| phone_address_country | The country the number was allocated under, against the country the address stands in. Both come from registers; neither is a guess. | |
| ip_address_country | Where the request came from, against where the post would go. Withheld when the IP is a VPN exit, a relay or a datacenter egress. | |
| ip_phone_country | The same comparison against the numbering plan, withheld under the same conditions. | |
| ip_address_distance | How far apart the two places are, in kilometres, as evidence only. Measured just once the two countries already agree and the IP location is graded high confidence. It never raises a flag. | |
| email_ip_disposable_datacenter | A burner address arriving from hosting rather than from a consumer network. | |
| email_ip_disposable_anonymous | A burner address arriving over a VPN, a proxy or Tor. | |
| email_address_free_provider_mail_drop | A free consumer mailbox beside a commercial mail-receiving agency. | |
| email_phone_disposable | Both contact details on disposable lists: neither can receive anything for long. |
Every row quotes both sides.
This is the shape of one entry in comparisons. Nothing is asserted that you cannot see the working for, and a comparison that was not made says why it was not made.
| Field | Type | What it tells you |
|---|---|---|
| comparison | Which of the eight checks this row is. | |
| left / right | Both sides, each naming the signal, the field on that signal’s own block, and the value it held. This is the working: nothing is asserted you cannot read for yourself. | |
| result | agree when the values matched or the pairing held, differ when two known values disagreed, not_compared when nothing was settled. A pairing holds only when both sides are true — two known-false fields are not a disagreement, they are simply not the thing being looked for. | |
| withheld | Why nothing was settled: missing_value when a side was null, low_confidence when the IP speaks for its operator rather than for a person. Null when the comparison was actually made. | |
| flag | The flag this comparison raised, if any. Flags live in flag rather than reason, because they describe two signals rather than one. | |
| distance_km | Filled only on ip_address_distance, as evidence. No threshold is applied and no flag is ever raised from it: there is no honest distance at which a person stops being themselves. |
Seven flags, and nothing above them.
The flags are the whole output. Each names one thing that is true about two fields you sent, each has its own reference page, and none of them is summed, weighted or rolled into a verdict.
| Flag | What it says |
|---|---|
| phone_country_differs_from_address_country | The numbering plan the phone belongs to is not the country the address stands in. |
| ip_country_differs_from_address_country | The request came from one country and the post would go to another — raised only where the IP genuinely speaks for a place. |
| ip_country_differs_from_phone_country | The same difference against the numbering plan. |
| disposable_email_from_datacenter_ip | A disposable mailbox arriving from a datacenter range rather than a consumer network. |
| disposable_email_from_anonymous_network | A disposable mailbox arriving over a VPN, a proxy or Tor. |
| mail_drop_address_with_free_email_provider | A free consumer mailbox given alongside an address a mail-receiving agency operates. |
| disposable_phone_with_disposable_email | Both the mailbox and the number are on disposable lists: neither can receive anything for long. |
Signup, onboarding, marketplace vetting.
Vet a signup with what you already collect
At the moment before there is an order, a card or a payment, the contact details are all you have. A phone allocated in one country beside an address in another, a burner mailbox arriving from a datacenter, a mail-drop address beside a free consumer provider: none of that is visible to a single lookup, and all of it is visible here.
Write a rule you can defend
The output is named flags and quoted values, so a review queue can show the reviewer exactly which two fields disagreed and what each of them said. A rule written from those will fit your own customers better than a generic score, and you will be able to explain it to the person it was applied to.
One call instead of five
Each signal returns its own full answer, field for field, so the consistency call replaces the separate lookups rather than adding to them. Each leg bills exactly as it would on its own endpoint, and the analysis on top is free.
Common questions about consistency checks.
Do I get a score or a risk level?
No. There is no score, no risk level, no verdict and no recommendation, and there will not be one. What you get is country_agreement over the three country comparisons, the list of comparisons with both sides quoted, and the flags that were raised. A difference between two fields is a fact about the fields, not a judgement about a person — so the endpoint hands you the evidence and stops, and you weigh it against what you know about your own customers.
Which signals can I send?
Five: email, phone, ip, address and entity. Any one of them, or any combination — a body with none of the five is a validation error. Each field takes what its own single-lookup endpoint takes, so address is an object with either a written address or the separated fields, always with its own country, while the top-level country is the region hint for the phone number only. Every comparison is arithmetic over fields those five pipelines already computed: nothing here runs a check the single endpoints do not.
What happens when I only send two signals?
You get the comparisons those two make possible, and silence for the rest. A comparison is emitted only when both of its signals answered, so a signal nobody asked about produces no row at all rather than a row saying nothing could be compared. The signals block still carries all five keys, with null for the ones you did not ask for.
Why is the country comparison missing for my VPN user?
Because it was withheld on purpose. When the IP is a VPN exit, a relay or a datacenter egress, the country belongs to the network operator rather than to the person, so every country comparison involving that IP answers not_compared with withheld: low_confidence. Flagging it would flag everyone who uses a VPN, which is a great many ordinary people. The IP block still carries is_anonymous, is_relay and is_datacenter if the network itself is what you want to act on.
What is distance_km for, if it never raises a flag?
Evidence, and nothing more. It is measured only once the IP and address countries already agree and the IP location is graded high confidence, and it is reported so you can see how far apart the two places were. No threshold is applied, because there is no honest distance at which a person stops being themselves — plenty of people order to an address they are not currently sitting at.
What does it cost?
Each signal bills exactly as its own single-lookup endpoint: 1 credit for a definitive answer, free when the answer is no, and free for a seven-day repeat of the same input on the same account. meta.credits_used is the sum of the per-signal costs, and the consistency analysis itself is always free — it reads answers you already paid for and adds no dataset, no network call and no third party. A call that runs out of credits part-way keeps the signals it paid for, answers 200 and reports meta.stopped_reason; only a call refused on its very first signal answers 402.
Is anything about the call stored?
Nothing beyond what each product already logs for its own lookup. The comparisons are computed and discarded with the response. Suppression lists are not applied here either: a consistency check is a fresh vetting decision, and a fresh lookup is how an address earns its way off a list. The endpoint keeps its own rate-limit bucket, because one call fans out to as many as five lookups.
Guides
All guidesCompare your first five signals.
Create a key, send one POST request, and keep free lookups every month on us.