Skip to content

API reference/ip

Queue a bulk IP lookup run

POST/api/v1/ip/bulk

authentication
Secret API key as a bearer token
billing
`preview: true` is free and queues nothing. A started run bills each row like a single lookup as it is processed; repeats inside the run and 7-day repeats are free, so what a run adds to your spend usually comes to no more than the `credits_upper_bound` the preview answered (other IP traffic can use lookups from a block the run opened, which can put the job's own `credits_used` slightly above it).

Queues up to 100,000 addresses and processes them in the background. Creation answers 202 with the job's id and status. Poll GET /api/v1/ip/bulk/{jobId} for progress, then download the result CSV from GET /api/v1/ip/bulk/{jobId}/results: one row per input address with every response field as a column. Jobs and their files are kept for 30 days.

Rows repeating an address already in the list are looked up once and counted in duplicate_count. A run that exhausts the balance stops with stopped_reason: "insufficient_credits" and keeps everything answered so far. Because nothing about an IP lookup is stored, the input list and the result file are the only copies and both are deleted with the job.

With a webhook_url, the finish (completed, failed, or cancelled) is POSTed there as { "event": "bulk_ip_job.finished", "job": { … } }, signed with webhook_secret exactly like bulk email 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 field 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.

credits_upper_bound is a CEILING, not a quote: one credit per five unique addresses, because IP lookups bill in blocks of five. Rows come back free for reasons no preview can know in advance — a repeat inside the seven-day window, a reserved, private or unallocated address, IP lookups already prepaid on the account — so what a run adds to your spend usually comes to less than the ceiling. Other IP traffic on the account can use prepaid lookups from a block the run opened, so the job's own credits_used can come out slightly above it. It is priced against the mapping in play, so correcting a column at start re-prices the run rather than holding you to the figure the guess produced — ask for the draft again after a correction if you want the new number. What it actually cost is credits_used on the finished job.

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
ipsstring[]1 to 100,000 IPv4 or IPv6 addresses. Required unless the body carries `csv` with `preview: true`, or `draft_id`.
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: a CSV's columns have to be worked out, and working them out and billing for the answer in one 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": "address_line1", "2": "postal_code"}. 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. Goes with `draft_id`, and only for a draft read from a `csv`.
webhook_urlstring | nullAn https URL to notify when the run finishes.
privacyboolean | nullKeep nothing about these lookups; every address is billed as a fresh lookup.

Example request

curl -X POST https://spaw.co/api/v1/ip/bulk \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
  "ips": [
    "8.8.8.8",
    "103.124.165.2"
  ],
  "webhook_url": "https://example.com/hooks/spaw"
}'
const response = await fetch('https://spaw.co/api/v1/ip/bulk', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer sk_live_…',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "ips": [
      "8.8.8.8",
      "103.124.165.2"
    ],
    "webhook_url": "https://example.com/hooks/spaw"
  }),
});
const result = await response.json();
import requests

response = requests.post(
    'https://spaw.co/api/v1/ip/bulk',
    headers={'Authorization': 'Bearer sk_live_…'},
    json={
        'ips': [
            '8.8.8.8',
            '103.124.165.2'
        ],
        'webhook_url': 'https://example.com/hooks/spaw'
    },
)
result = response.json()
$ch = curl_init('https://spaw.co/api/v1/ip/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([
        'ips' => [
            '8.8.8.8',
            '103.124.165.2'
        ],
        '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_ip_bulk_job([
    '8.8.8.8',
    '103.124.165.2'
], webhook_url='https://example.com/hooks/spaw')
import Spaw from 'spaw';

const spaw = new Spaw({ apiKey: 'sk_live_…' });
const result = await spaw.createIpBulkJob([
    '8.8.8.8',
    '103.124.165.2'
], {
    webhookUrl: 'https://example.com/hooks/spaw'
});
use Spaw\Client;

$spaw = new Client('sk_live_…');
$result = $spaw->createIpBulkJob([
    '8.8.8.8',
    '103.124.165.2'
], 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": 512,
            "status": "queued",
            "total": 2,
            "duplicate_count": 0,
            "processed": 0,
            "low": 0,
            "medium": 0,
            "high": 0,
            "credits_used": 0,
            "stopped_reason": null,
            "cancel_requested": false,
            "webhook_status": null,
            "created_at": "2026-09-04T10: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": 512,
            "status": "processing",
            "total": 2,
            "duplicate_count": 0,
            "processed": 1,
            "low": 0,
            "medium": 1,
            "high": 0,
            "credits_used": 1,
            "stopped_reason": null,
            "cancel_requested": false,
            "webhook_status": null,
            "created_at": "2026-09-04T10: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 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