# Cross-signal consistency

One call 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 reports where the answers agree and where they do not. 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 those is visible to a single lookup, and all of them are visible here.

It is built for signup, onboarding and marketplace vetting — the moment before there is an order, a card or a payment, when the contact details are all you have. No new dataset stands behind it: every comparison is arithmetic over fields the four pipelines already computed, which is why the analysis is free.

```
curl https://spaw.co/api/v1/consistency \
    -H "Authorization: Bearer sk_live_…" \
    -H "Content-Type: application/json" \
    -d '{"email": "ada@example.com", "phone": "+442079460018",
         "ip": "81.2.69.142",
         "address": {"address": "10 Downing Street, London, SW1A 2AA", "country": "GB"}}'
```

## Sending the signals

Send any one of `email`, `phone`, `ip` and `address`, or any combination of them; a body with none of the four is a validation error. Each field takes exactly 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`. The top-level `country` is the region hint for the phone number only.

The signals run in a fixed order — `ip`, `phone`, `address`, `email` — because a JSON object has no input order, and because email is the only leg that does DNS and a mailbox handshake, so a call that runs out of credits stops at the slowest leg last.

The per-product extras are deliberately not accepted here: there is no `hlr`, no `deliverability`, no `abuse_contact` and no freshness controls. Call the product endpoint directly when you want those; this one is for the comparison.

## The per-signal blocks

`data.signals` carries one entry per product, and all four keys are always present. Each `data` is exactly what that product's own endpoint returns, field for field — the 27 email fields, the 33 phone fields, the 46 IP fields, the 45 address fields — so nothing has to be learned twice and nothing is summarised away. Beside it, `meta` says what that one signal cost and whether it was a repeat.

A key is `null` when you did not ask for that signal, and also when the call ran out of credits before reaching it. The difference is in `meta.requested` against `meta.processed`, and in `meta.stopped_reason`.

## Comparisons and flags

Eight comparisons, each 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.country` ↔ `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 | `ip.country` ↔ `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 | `ip.country` ↔ `phone.country` | The same comparison against the numbering plan, withheld under the same conditions. |
| ip_address_distance | `ip.latitude/longitude` ↔ `address.latitude/longitude` | How far apart the two places are, in kilometres, as evidence only. Measured just once the countries already agree and the IP location is graded high confidence. It never raises a flag. |
| email_ip_disposable_datacenter | `email.disposable` ↔ `ip.is_datacenter` | A burner address arriving from hosting rather than from a consumer network. |
| email_ip_disposable_anonymous | `email.disposable` ↔ `ip.is_anonymous` | A burner address arriving over a VPN, a proxy or Tor. |
| email_address_free_provider_mail_drop | `email.free_provider` ↔ `address.is_mail_drop` | A free consumer mailbox beside a commercial mail-receiving agency. |
| email_phone_disposable | `email.disposable` ↔ `phone.is_disposable` | Both contact details on disposable lists: neither can receive anything for long. |

Every entry quotes both sides — `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. The `result` is `agree`, `differ` or `not_compared`, and each flag has its own page under https://spaw.co/docs/reasons#consistency.

## When a comparison is withheld

Two rules keep the analysis from manufacturing evidence, and both report themselves in `withheld` rather than settling the comparison quietly.

`missing_value` — one of the two fields was null. 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.

`low_confidence` — the IP is a VPN exit, a relay or a datacenter egress, so its country belongs to the operator rather than to the person. Every country comparison involving that IP is withheld. Reporting a difference there would flag everyone who uses a VPN, which is a great many ordinary people. Read `is_anonymous`, `is_relay` and `is_datacenter` on the IP block if the network itself is what you want to act on.

## The consistency block

| field | type | meaning |
| --- | --- | --- |
| signals_compared | `string[]` | Which of email, phone, ip and address answered and were available to compare. |
| country_agreement | `'agree' \| 'differ' \| 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, null when none of them could be made — never silently agree. |
| flags | `string[]` | Every flag raised, in comparison order. Each has its own reference page. |
| comparisons | `object[]` | Every comparison that was possible, including the ones withheld, each with both sides quoted. |

## Bulk runs & webhooks

The same check over a whole list. A bulk consistency run takes up to 100,000 rows and works through them in the background — upload or paste from the Bulk runs tab on the dashboard, or call `POST /api/v1/consistency/bulk` (https://spaw.co/docs/api/create-consistency-bulk-job). Each row carries any subset of `email`, `phone`, `ip` and `address` and at least one of them, exactly as the single call does, and a row's own `country` wins over the list-level one. Poll the job for progress, then download the result CSV: your own columns first, then each signal as it was read, then what agreed, what did not, and what could not honestly be settled. `?variant=flagged`, `unflagged` or `uncompared` narrows the download to the rows you are actually going to work through.

Billing is the single call's billing, row by row: each leg costs what its own product costs, a repeat inside seven days is free, and the comparison itself is free. A run that exhausts the balance stops rather than half-charging, keeps every row it paid for downloadable, and is resumed from the top with `POST /api/v1/consistency/bulk/{jobId}/resume` (https://spaw.co/docs/api/resume-consistency-bulk-job) once you have topped up — the rows it already answered come back as free repeats. Cancelling stops a queued run on the spot and a running one at its next checkpoint, and the partial file stays downloadable. Runs, their input and their results are kept for 30 days and then deleted together.

Pass a `webhook_url` at creation and the finish — completed, failed, or cancelled — is POSTed there as `{"event": "bulk_consistency_job.finished", "job": {…}}`, signed with the secret creation returns once: `X-Spaw-Signature-V2` carries `t={unix seconds},v2={hex}`, the HMAC-SHA256 of `v2:{t}:{raw body}`, and a receiver should refuse anything whose `t` is more than five minutes from its own clock so a captured delivery cannot be replayed later. The original `X-Spaw-Signature`, over the body alone, is sent beside it until 1 March 2027. Transient delivery failures are retried twice.

If a delivery fails — your endpoint was down for the half-minute the three attempts cover — ask for it again rather than falling back to polling: press **Send webhook again** on the run in the dashboard, or call `POST /api/v1/consistency/bulk/{jobId}/webhook/redeliver` (https://spaw.co/docs/api/redeliver-consistency-bulk-webhook). The body is byte-for-byte the body the first delivery carried, rebuilt from the run's own frozen row, so the two reconcile. The signature is new, and has to be: a receiver refuses anything whose `t` is more than five minutes from its own clock, so verify a redelivery exactly as you verify any other delivery. Nothing deduplicates it for you — a receiver that gets both the original and the redelivery sees `bulk_consistency_job.finished` twice, and `job.id` is the key to deduplicate on. A run created without a `webhook_url` answers `409 WEBHOOK_NOT_CONFIGURED` and one that has not settled answers `409 JOB_NOT_FINISHED`; a run whose last delivery succeeded is not refused, because a receiver can lose a message it has already acknowledged. The delivery is queued rather than made while you wait — one attempt takes half a minute against an endpoint that is down, which is the endpoint you are asking about — so the call answers `202` and the run reads `webhook_status: "pending"` until a worker has an answer, then `delivered` or `failed` with what your endpoint said in `webhook_detail`. The dashboard shows the same three states beside the run. It is free and capped at ten a minute counted per signed-in user: neither per key nor per account, so two keys held by one person share the one budget while two teammates have one each.

## What this endpoint will not do

There is no score, no risk level, no verdict and no recommendation, and there will not be one. The flags are the whole output. A difference between two fields is a fact about the fields: 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 a single number would hide which fact drove it, and a number that decides about a person is a number that has to be explained to that person.

So the endpoint hands you the evidence and stops. Weigh it against what you know about your own customers, and write your own rule from named flags you can quote back — that rule will be better than a generic one, and you will be able to defend it.

## Billing and privacy

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 free — it reads answers you already paid for.

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 billed is ever discarded.

Suppression lists are not applied here: a consistency check is a fresh vetting decision, and a fresh lookup is how an address earns its way off a list. Nothing about the call is stored beyond what each product already logs for its own lookup, and the comparisons themselves are computed and discarded with the response. The endpoint keeps its own rate-limit bucket, because one call fans out to as many as four lookups.

Reference: https://spaw.co/docs/cross-signal-consistency
