# Check several signals against each other

`POST /api/v1/consistency`

- Authentication: Secret API key as a bearer token
- Billing: Each signal bills exactly as its own single-lookup endpoint: 1 credit per definitive answer, free when the answer is no, and free for a 7-day repeat of the same input on the same account. `meta.credits_used` is the sum, and the consistency analysis itself is free. A call that runs out of credits part-way keeps the signals it paid for and reports `meta.stopped_reason`; only a call refused on its first signal answers 402.
- Group: Consistency

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 per-signal blocks under `signals` are exactly what the single-lookup endpoints return, field for field. All five keys are always present: a signal you did not ask for, or one the call did not reach, is `null`. The signals run in a fixed order — `ip`, `phone`, `entity`, `address`, `email` — because a keyed body has no input order, and because email is the only leg that does DNS and a mailbox probe, so a call that runs out of credits stops at the slowest leg last.

The `consistency` block on top is free. `comparisons` lists each check with both sides quoted — the signal, the field and the value that decided it — so nothing is asserted that you cannot see the working for. A comparison appears only when both of its signals answered; a signal nobody asked about produces silence, not a row. Where one side is `null` the result is `not_compared` with `withheld: "missing_value"`, never a disagreement, because a null field means "not evaluated" and never "no". Where the IP is a VPN exit, a relay or a datacenter egress, the country comparisons answer `not_compared` with `withheld: "low_confidence"`: that location belongs to the operator rather than to the person, and reporting a difference there would manufacture evidence against everyone who uses a VPN.

`country_agreement` rolls up the three country comparisons between the person's own signals — `differ` if any of them differed, `agree` if at least one agreed and none differed, and `null` when none of them could be made. `flags` names the disagreements and the pairings that held: `phone_country_differs_from_address_country`, `ip_country_differs_from_address_country`, `ip_country_differs_from_phone_country`, `disposable_email_from_datacenter_ip`, `disposable_email_from_anonymous_network`, `mail_drop_address_with_free_email_provider`, `disposable_phone_with_disposable_email`, `entity_country_differs_from_address_country`, `entity_name_differs_from_address_organization`. The `ip_address_distance` comparison carries `distance_km` as evidence only, and never raises a flag: there is no honest distance at which a person stops being themselves.

The business identifier is compared against the postal address, the only other signal carrying a company name and a country of its own. `entity_address_country` puts the country of registration beside the country the address stands in; it is deliberately **not** part of `country_agreement`, because where a company is registered is not where a person is, and a Delaware company with a London office would otherwise read as a top-level disagreement. `entity_address_organization` compares the registered legal name with the organisation written on the address, and the only claim it makes is a normalised exact match: both sides are folded to their letters and digits and compared as strings, with case, accents (`Société` and `Societe` fold together), an ampersand against the word `and`, apostrophes, full stops in initials, and company-form words such as limited, ltd, plc, llp and inc taken out first — so a difference here is a difference in the name rather than in how it was typed. There is no similarity score, because no threshold honestly decides whether two names are one company — so `differ` means the two names are not the same name, not that the address belongs to somebody else. A value that folds away to nothing, such as an organisation line reading only "Ltd", withholds the comparison as a missing value.

There is no score, no risk level, no verdict and no recommendation, and there will not be one. The flags are the whole output, and 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 and browse through a VPN. Nothing about the call is stored beyond what each product already logs for its own lookup.

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string | null | no | The email address to verify. At most 254 characters. |
| `phone` | string | null | no | The phone number to validate, in E.164 or in national notation with `country`. At most 32 characters. |
| `country` | string | null | no | The ISO region the phone number should be read against. It applies to the phone signal only; the postal address carries its own country. At most 2 characters. |
| `ip` | string | null | no | The IPv4 or IPv6 address the request came from. |
| `address` | object | null | no | The postal address, in the same shape `POST /api/v1/address` takes — either a written `address` or the separated fields, always with `country`. Send `organization` to have the company name compared with the register's. |
| `entity` | string | object | no | The business identifier, in the same shape `POST /api/v1/entity` takes. A bare string is read as a LEI, which is the only type needing no country. |
| `entity.identifier` | string | no | At most 64 characters. |
| `entity.type` | string | no | One of: lei, company_number. |
| `entity.country` | string | null | no | Required when `type` is `company_number`. At most 2 characters. |

## Example request

```bash
curl -X POST https://spaw.co/api/v1/consistency \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "ada@example.com",
  "phone": "+442079460018",
  "country": "GB",
  "ip": "81.2.69.142",
  "address": {
    "address": "10 Downing Street, London, SW1A 2AA",
    "organization": "ACME HOLDINGS LTD",
    "country": "GB"
  },
  "entity": {
    "identifier": "213800QILIUD4ROSUO03",
    "type": "lei"
  }
}'
```

## Responses

### 200 — Each signal's own answer, and the comparisons between them.

```json
{
    "success": true,
    "data": {
        "consistency": {
            "signals_compared": [
                "email",
                "phone",
                "ip",
                "address",
                "entity"
            ],
            "country_agreement": "differ",
            "flags": [
                "ip_country_differs_from_address_country",
                "ip_country_differs_from_phone_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,
                    "distance_km": null
                },
                {
                    "comparison": "ip_address_country",
                    "left": {
                        "signal": "ip",
                        "field": "country",
                        "value": "DE"
                    },
                    "right": {
                        "signal": "address",
                        "field": "country",
                        "value": "GB"
                    },
                    "result": "differ",
                    "withheld": null,
                    "flag": "ip_country_differs_from_address_country",
                    "distance_km": null
                }
            ]
        }
    },
    "meta": {
        "requested": 5,
        "processed": 5,
        "credits_used": 5,
        "credits_remaining": 995,
        "stopped_reason": null,
        "request_id": "req_01m1kgdm4xngzmbmff68g94w0c"
    }
}
```

### 401 — The key is missing, malformed, or revoked.

```json
{
    "success": false,
    "error": {
        "code": "UNAUTHENTICATED",
        "message": "Provide a valid API key as a bearer token.",
        "request_id": "req_01m1kgdm4xngzmbmff68g94w0c"
    }
}
```

### 402 — The balance is empty. The lookup did not run.

```json
{
    "success": false,
    "error": {
        "code": "INSUFFICIENT_CREDITS",
        "message": "Your credit balance is empty.",
        "request_id": "req_01m1kgdm4xngzmbmff68g94w0c"
    }
}
```

### 422 — The request body could not be validated; `error.errors` lists the fields.

```json
{
    "success": false,
    "error": {
        "code": "VALIDATION_FAILED",
        "message": "The email field is required.",
        "errors": {
            "email": [
                "The email field is required."
            ]
        },
        "request_id": "req_01m1kgdm4xngzmbmff68g94w0c"
    }
}
```

### 429 — Over 5 requests per second for the key. Retry after the limit resets.

```json
{
    "success": false,
    "error": {
        "code": "RATE_LIMITED",
        "message": "Too many requests. Retry after the limit resets.",
        "request_id": "req_01m1kgdm4xngzmbmff68g94w0c"
    }
}
```

## Error codes

- `UNAUTHENTICATED` — https://spaw.co/docs/errors/UNAUTHENTICATED
- `VALIDATION_FAILED` — https://spaw.co/docs/errors/VALIDATION_FAILED
- `INSUFFICIENT_CREDITS` — https://spaw.co/docs/errors/INSUFFICIENT_CREDITS
- `RATE_LIMITED` — https://spaw.co/docs/errors/RATE_LIMITED
- `KEY_SCOPE_DENIED` — https://spaw.co/docs/errors/KEY_SCOPE_DENIED
- `EMAIL_NOT_VERIFIED` — https://spaw.co/docs/errors/EMAIL_NOT_VERIFIED

---

Canonical page: https://spaw.co/docs/api/check-consistency · OpenAPI document: https://spaw.co/openapi.json · All endpoints: https://spaw.co/docs/api
