Skip to content

How to read a consistency report

published · September 10, 2026

Four fields, ten comparisons, both sides of each one quoted, and deliberately no score. A field-by-field reading, and how to write a rule you can defend.

One call takes any subset of an email address, a phone number, an IP address, a postal address and a business identifier, runs each through the product that owns it, and reports where the answers agree and where they do not. The output is deliberately not a score. It is a list of comparisons with both sides quoted, and reading it well is a matter of knowing which of three things each row is saying. This guide goes through the response in the order you will meet it.

What comes back

data.signals carries one entry per product, and every key is present whether or not you asked for it. Each data is exactly what that product's own endpoint returns, field for field — nothing summarised away, nothing to learn twice. Beside it, meta says what that one signal cost and whether it was a seven-day repeat.

A null there means one of two things: you did not ask for that signal, or the call ran out of credits before reaching it. meta.requested against meta.processed, and meta.stopped_reason, tell you which.

data.consistency is the analysis on top, and it has four fields.

Field Type What it holds
signals_compared string[] Which signals answered and were available to compare.
country_agreement 'agree' | 'differ' | null The roll-up over the three country comparisons between a person's own signals — and nothing else.
flags string[] Every flag raised, in comparison order. Each has its own reference page.
comparisons object[] Every comparison that could be made, including the ones withheld, each quoting both sides.

The ten comparisons

A comparison is emitted only when both of its signals answered. A signal nobody asked about produces silence, not a row saying nothing could be compared.

comparison Fields What it says
phone_address_country phone.countryaddress.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 ip.countryaddress.country Where the request came from, against where the post would go. Withheld for a VPN exit, a relay or a datacenter egress.
ip_phone_country ip.countryphone.country The same comparison against the numbering plan, withheld under the same conditions.
ip_address_distance ip coordinates ↔ address coordinates How far apart the two places are, in kilometres, as evidence only. It never raises a flag.
email_ip_disposable_datacenter email.disposableip.is_datacenter A burner address arriving from hosting rather than a consumer network.
email_ip_disposable_anonymous email.disposableip.is_anonymous A burner address arriving over a VPN, a proxy or Tor.
email_address_free_provider_mail_drop email.free_provideraddress.is_mail_drop A free consumer mailbox beside a commercial mail-receiving agency.
email_phone_disposable email.disposablephone.is_disposable Both contact details on disposable lists: neither can receive anything for long.
entity_address_country entity.countryaddress.country The country a business is registered in, against the country of the address given for it.
entity_address_organization entity.nameaddress.organization The registered legal name against the company name written on the address.

Three results, and a fourth column that explains them

Every row carries left and right — each naming the signal, the field and the value it held — so nothing is asserted that you cannot see the working for. result is one of three values.

agree — the two values matched. For a country pair that means the same country. For a pairing such as email_ip_disposable_datacenter it means both sides were true, which is the only positive that pairing has.

differ — the two values are both present and are not the same. This is a fact about two fields. It is not a fact about a person, and the response never dresses it up as one.

not_compared — no comparison was made. Either the pairing simply did not hold (two known-false fields are not a disagreement), or the comparison was withheld, in which case withheld says why: missing_value when one of the fields was null, low_confidence when the IP does not honestly speak for a place.

That distinction between a null and a "no" runs through every Spaw product. 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 somebody.

distance_km is populated on exactly one row, ip_address_distance, and only when the two countries already agreed and the IP location is graded high confidence. It carries no flag at any value, because there is no honest distance at which a person stops being themselves.

country_agreement is narrower than it looks

It is a roll-up over three comparisons only: phone_address_country, ip_address_country and ip_phone_country. It reads differ if any of them differed, agree if at least one agreed and none differed, and null when none of them could be made — so a partial answer never reads as agreement it did not establish.

The entity comparisons are deliberately not folded into it. That roll-up is about where a person is, and a company's country of registration is not that: a Delaware company with a London office is entirely ordinary, and counting it as a disagreement would report a top-level problem about nothing. The flag still names the difference for anyone who wants it.

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.

That is a design decision, not a gap. People travel. People move house and keep the old number. People emigrate and keep a foreign mobile for a decade. People browse through a VPN because they would rather not be tracked. Rolling those facts into a single number hides which one drove it — and a number that decides about a person is a number you will one day have to explain to that person.

So the endpoint hands you the evidence and stops. Write your own rule from the named flags, and you will get a better rule than a generic one, because you know your own customers and we do not.

A workable shape for that rule:

  • Nothing on its own is a rejection. One country mismatch is a person on holiday as often as it is anything else.
  • Count the flags rather than weighing them. Two or more unrelated flags on one signup is a different situation from one, and it is a rule you can explain in a sentence.
  • Route, do not block. Send a flagged signup to a manual review queue or to a second factor. The cost of a wrong hold is a message; the cost of a wrong block is a customer.
  • Log the flags, not a decision. When somebody asks why an application was held, you want the row that says left: {signal: ip, field: country, value: DE} and right: {signal: address, field: country, value: GB} — not a number nobody can reconstruct.
  • Read the per-signal blocks too. The flags cover what two signals say about each other. Each product's own risk signals are on its own block and are not repeated here.

Billing, and what is stored

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. The consistency analysis itself is free — it is arithmetic over fields the pipelines already computed, with no dataset behind it.

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. Nothing you were billed for is discarded.

Suppression lists are not applied here: a consistency check is a fresh vetting decision, and a fresh lookup is how an entry earns its way off a list. The comparisons themselves are computed and discarded with the response.

What to do next

Related

markdown version: /guides/how-to-read-a-consistency-report.md

Verify addresses the same way

The Spaw API runs every check described here on each lookup, with an SMTP handshake that never sends mail. 10 free lookups a month, no card required.

Get your API key

More guides