# Queue a bulk consistency run

`POST /api/v1/consistency/bulk`

- Authentication: Secret API key as a bearer token
- Billing: `preview: true` is free and queues nothing. A started run bills each row as the SUM OF THE LEGS IT ACTUALLY RAN, each on that product's own rules — 1 credit for a definitive answer, free when the answer is no, free for a 7-day repeat of the same input on the same account — and the comparison on top is free. So a row costs between nothing and four credits, which is exactly what the same rows would have cost one at a time through `POST /api/v1/consistency`. None of the per-product extras is available here and none is charged: no HLR, no deliverability partner, no privacy mode. Suppression lists are deliberately not consulted, matching the single call: a consistency check is a fresh vetting decision.
- Group: Consistency

The cross-signal check over a whole list. Queues up to 100,000 rows and processes them in the background: creation answers `202` with the job's id and status, `GET /api/v1/consistency/bulk/{jobId}` reports progress, and `GET /api/v1/consistency/bulk/{jobId}/results` streams one CSV row per input row — the four signals as submitted, then what agreed, what did not, and what could not honestly be settled.

Each item carries any subset of `email`, `phone`, `ip` and `address`, and at least one of them. **A row that carries two of the four is a perfectly good consistency check of those two** and is run as one; the comparisons its missing signals would have been part of are simply absent from the row, in the same way a signal you did not send produces silence rather than a row on the single call. A row's `country` applies to that row alone and wins over the list-level one.

Rows repeating an ENTIRE row already in the list are counted in `duplicate_count`. That is stricter than the other four bulk runs on purpose: repeats are free per lookup here, not per row, so two rows sharing an email address but naming different phone numbers get one free leg and one charged one — counting them as duplicates would take a credit off the preview's ceiling that the run then spends.

With a `webhook_url`, the finish (`completed`, `failed`, or `cancelled`) is POSTed there as `{ "event": "bulk_consistency_job.finished", "job": { … } }`, signed with `webhook_secret` exactly like the other four bulk runs: `X-Spaw-Signature-V2` carries `t={unix seconds},v2={lowercase hex}`, the HMAC-SHA256 of `v2:{t}:{raw body}`, and a receiver should refuse a `t` more than five minutes from its own clock so a captured delivery cannot be replayed at it later. The original `X-Spaw-Signature`, over the body alone, is sent beside it until 1 March 2027. Send an `Idempotency-Key` header to make creation safe to retry: a repeat of the same request answers the job the first attempt created, as `200` with `Idempotent-Replayed: true`; the same key with a different request answers `409 IDEMPOTENCY_KEY_REUSED`.

### Reading a list before you start it

Send `preview: true` with the same body — or `csv` with a raw CSV instead of the item list — and the call answers `200` with `data.draft` instead of queueing anything: what the list was read as, which column filled which signal and whether a heading named it or we guessed, the first rows as parsed, the repeats, the balance, and `credits_upper_bound`. Nothing is charged. The mapping matters more here than anywhere else, because a row is up to four lookups: `credits_per_row` is the number of SIGNALS the columns feed, so a file whose columns only reach an email address and a phone number is priced at two a row rather than four.

`credits_upper_bound` is a CEILING, not a quote. Rows come back free for reasons no preview can know in advance — a leg that repeats inside the seven-day window, a verdict that is not charged for, a signal a row turns out not to carry — so a run usually costs less than the ceiling and never more.

Start it with `{"draft_id": <id>}`, adding `mapping` to correct a column we read wrongly. Starting consumes the draft; one left unstarted is deleted, with the rows it holds, after `retention_hours`.

## Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | string | no | A value unique to this request, so a retry after a timeout answers the same job instead of queueing a second one. 1 to 128 characters. |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | object[] | no | 1 to 100,000 rows, each naming at least one of the four signals. Required unless the body carries `csv` with `preview: true`, or `draft_id`. |
| `items[].email` | string | null | no | At most 254 characters. |
| `items[].phone` | string | null | no | In E.164, or in national notation with a country. At most 32 characters. |
| `items[].ip` | string | null | no | An IPv4 or IPv6 address. |
| `items[].address` | string | object | null | no | A written address, or an object of the same separated fields `POST /api/v1/address` takes. Its own `country` wins over the item's. |
| `items[].country` | string | null | no | This row's country. It serves two legs — the region the phone number is read against and the country the postal address is measured against — and each leg ignores it when its own table does not cover it. At most 2 characters. |
| `country` | string | null | no | An ISO 3166-1 alpha-2 code applied to every row that does not name its own. It has to be known to the dialling plan or to the postal format table; a code neither knows is a `422` rather than a setting that quietly does nothing. At most 2 characters. |
| `preview` | boolean | no | Read the list and answer `data.draft` — what we made of it, the repeats, the ceiling on the cost and the balance — with nothing queued and nothing billed. Start it afterwards with `draft_id`. Default: . |
| `csv` | string | null | no | A raw CSV to read instead of the item list, with the column mapping answered back. Preview only, on purpose: one header row has to yield an email address, a phone number, an IP address and the address fields at once, and guessing at a customer's columns and billing them for the guess in the same request is what the preview exists to stop. At most 5,000,000 characters. |
| `draft_id` | integer | null | no | Start the run a preview answered with. The rows and the options come from the draft, so nothing else in the body applies except `mapping`. A draft can be started once; an `Idempotency-Key` still makes the call safe to retry. |
| `mapping` | object | null | no | Corrects the draft's column mapping, as column index to field — {"1": "email", "4": "address_line1"}. The fields are `email`, `phone`, `ip`, `country` and the address ones (`address`, `address_line1`, `address_line2`, `organization`, `dependent_locality`, `locality`, `administrative_area`, `postal_code`, `po_box`). It REPLACES the detected mapping rather than merging into it, so a column can be taken off a field as well as put on one. A mapping that feeds none of the four signals is refused: a country column on its own checks nothing. |
| `webhook_url` | string | null | no | An https URL to notify when the run finishes. |

## Example request

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

## Responses

### 202 — The job was queued.

```json
{
    "success": true,
    "data": {
        "job": {
            "id": 41,
            "status": "queued",
            "total": 2,
            "duplicate_count": 0,
            "processed": 0,
            "flagged": 0,
            "unflagged": 0,
            "uncompared": 0,
            "credits_used": 0,
            "stopped_reason": null,
            "cancel_requested": false,
            "country": "GB",
            "webhook_status": null,
            "webhook_detail": null,
            "created_at": "2026-09-10T10:12:44+00:00",
            "finished_at": null,
            "webhook_secret": "8fJ2…40 characters…Qk1"
        }
    }
}
```

### 200 — Either a preview — `preview: true` answers `data.draft` and queues nothing — or a retry with an `Idempotency-Key` already used for this exact request, which answers `data.job` with `Idempotent-Replayed: true`.

```json
{
    "success": true,
    "data": {
        "job": {
            "id": 41,
            "status": "processing",
            "total": 2,
            "duplicate_count": 0,
            "processed": 1,
            "flagged": 1,
            "unflagged": 0,
            "uncompared": 0,
            "credits_used": 3,
            "stopped_reason": null,
            "cancel_requested": false,
            "country": "GB",
            "webhook_status": null,
            "webhook_detail": null,
            "created_at": "2026-09-10T10:12:44+00:00",
            "finished_at": null
        }
    }
}
```

### 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"
    }
}
```

### 409 — The `Idempotency-Key` was already used for a different list, country or webhook URL.

```json
{
    "success": false,
    "error": {
        "code": "IDEMPOTENCY_KEY_REUSED",
        "message": "This Idempotency-Key was already used for a different request.",
        "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
- `IDEMPOTENCY_KEY_REUSED` — https://spaw.co/docs/errors/IDEMPOTENCY_KEY_REUSED
- `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/create-consistency-bulk-job · OpenAPI document: https://spaw.co/openapi.json · All endpoints: https://spaw.co/docs/api
