Skip to content

API reference/consistency

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.

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 description
Idempotency-KeyheaderstringA 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 description
itemsobject[]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[].emailstring | nullAt most 254 characters.
items[].phonestring | nullIn E.164, or in national notation with a country. At most 32 characters.
items[].ipstring | nullAn IPv4 or IPv6 address.
items[].addressstring | object | nullA written address, or an object of the same separated fields `POST /api/v1/address` takes. Its own `country` wins over the item's.
items[].countrystring | nullThis 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.
countrystring | nullAn 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.
previewbooleanRead 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: .
csvstring | nullA 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_idinteger | nullStart 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.
mappingobject | nullCorrects 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_urlstring | nullAn https URL to notify when the run finishes.

Example request

curl -X POST https://spaw.co/api/v1/consistency/bulk \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "items": [
    {
      "email": "[email protected]",
      "phone": "+442079460018",
      "ip": "81.2.69.142",
      "address": "10 Downing Street, London, SW1A 2AA",
      "country": "GB"
    },
    {
      "email": "[email protected]",
      "ip": "81.2.69.143"
    }
  ],
  "country": "GB",
  "webhook_url": "https://example.com/hooks/spaw"
}'
const response = await fetch('https://spaw.co/api/v1/consistency/bulk', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer sk_live_…',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "items": [
      {
        "email": "[email protected]",
        "phone": "+442079460018",
        "ip": "81.2.69.142",
        "address": "10 Downing Street, London, SW1A 2AA",
        "country": "GB"
      },
      {
        "email": "[email protected]",
        "ip": "81.2.69.143"
      }
    ],
    "country": "GB",
    "webhook_url": "https://example.com/hooks/spaw"
  }),
});
const result = await response.json();
import requests

response = requests.post(
    'https://spaw.co/api/v1/consistency/bulk',
    headers={'Authorization': 'Bearer sk_live_…'},
    json={
        'items': [
            {
                'email': '[email protected]',
                'phone': '+442079460018',
                'ip': '81.2.69.142',
                'address': '10 Downing Street, London, SW1A 2AA',
                'country': 'GB'
            },
            {
                'email': '[email protected]',
                'ip': '81.2.69.143'
            }
        ],
        'country': 'GB',
        'webhook_url': 'https://example.com/hooks/spaw'
    },
)
result = response.json()
$ch = curl_init('https://spaw.co/api/v1/consistency/bulk');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_…', 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS => json_encode([
        'items' => [
            [
                'email' => '[email protected]',
                'phone' => '+442079460018',
                'ip' => '81.2.69.142',
                'address' => '10 Downing Street, London, SW1A 2AA',
                'country' => 'GB'
            ],
            [
                'email' => '[email protected]',
                'ip' => '81.2.69.143'
            ]
        ],
        'country' => 'GB',
        'webhook_url' => 'https://example.com/hooks/spaw'
    ]),
]);
$result = json_decode(curl_exec($ch), true);
from spaw import Client

client = Client('sk_live_…')
result = client.create_consistency_bulk_job([
    {
        'email': '[email protected]',
        'phone': '+442079460018',
        'ip': '81.2.69.142',
        'address': '10 Downing Street, London, SW1A 2AA',
        'country': 'GB'
    },
    {
        'email': '[email protected]',
        'ip': '81.2.69.143'
    }
], country='GB', webhook_url='https://example.com/hooks/spaw')
import Spaw from 'spaw';

const spaw = new Spaw({ apiKey: 'sk_live_…' });
const result = await spaw.createConsistencyBulkJob([
    {
        email: '[email protected]',
        phone: '+442079460018',
        ip: '81.2.69.142',
        address: '10 Downing Street, London, SW1A 2AA',
        country: 'GB'
    },
    {
        email: '[email protected]',
        ip: '81.2.69.143'
    }
], {
    country: 'GB',
    webhookUrl: 'https://example.com/hooks/spaw'
});
use Spaw\Client;

$spaw = new Client('sk_live_…');
$result = $spaw->createConsistencyBulkJob([
    [
        'email' => '[email protected]',
        'phone' => '+442079460018',
        'ip' => '81.2.69.142',
        'address' => '10 Downing Street, London, SW1A 2AA',
        'country' => 'GB'
    ],
    [
        'email' => '[email protected]',
        'ip' => '81.2.69.143'
    ]
], country: 'GB', webhookUrl: 'https://example.com/hooks/spaw');

This endpoint has no console on its page. It queues a job that bills per row and can post a webhook when it finishes — far more than one click made while reading should be able to spend.

Responses

202The job was queued.

{
    "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"
        }
    }
}

200Either 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`.

{
    "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
        }
    }
}

401The key is missing, malformed, or revoked.

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

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

{
    "success": false,
    "error": {
        "code": "IDEMPOTENCY_KEY_REUSED",
        "message": "This Idempotency-Key was already used for a different request.",
        "request_id": "req_01m1kgdm4xngzmbmff68g94w0c"
    }
}

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

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

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

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

Error codes

Failures answer { success: false, error: { code, message, request_id } }. Each code has its own page.

markdown version·openapi.json