- 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; invalid numbers, repeats inside the run and 7-day repeats are free, so a run costs at most the `credits_upper_bound` the preview answered and usually less.
Queues up to 100,000 numbers and processes them in the background. Creation answers 202 with the job's id and status. Poll GET /api/v1/phone/bulk/{jobId} for progress, then download the result CSV from GET /api/v1/phone/bulk/{jobId}/results: one row per input number with every response field as a column. Jobs and their files are kept for 30 days.
Rows repeating a number already in the list (as submitted) 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 a phone lookup is stored, the input list and the result file are the only copies and both are deleted with the job. country applies to every row; the live carrier-network check is single lookups only.
With a webhook_url, the finish (completed, failed, or cancelled) is POSTed there as { "event": "bulk_phone_job.finished", "job": { … } }, signed with webhook_secret exactly like bulk email and IP 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. Rows come back free for reasons no preview can know in advance — a repeat inside the seven-day window, an entry on your suppression list, a verdict that is not charged for, a provider outage — so a run usually costs less than the ceiling and never more. 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-Key | header | string | 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 | description |
|---|---|---|
| phones | string[] | 1 to 100,000 numbers, in any common notation. Each item is a plain string, or an object carrying its own country. Required unless the body carries `csv` with `preview: true`, or `draft_id`. |
| phones[].phonerequired | string | The number, in any common notation. At most 64 characters. |
| phones[].country | string | null | This item's own ISO 3166-1 alpha-2 region code, for a number written without a calling code. Wins over the list-level country. |
| country | string | null | An ISO 3166-1 alpha-2 region code used to parse every number written without a calling code, unless the item names its own. |
| hlr | boolean | Ask for the live carrier check on every number. While the check is not enabled on the service it answers `hlr_checked` false at no extra cost; when it is, each item that answers is charged the premium credits under the single-lookup rules (free where the account paid for that number's check in the last 7 days and the network's answer is still held), and the balance is gated per item before any network query. Default: . |
| preview | boolean | 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 | A 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_id | integer | null | 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 | Corrects 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_url | string | null | An https URL to notify when the run finishes. |
Example request
curl -X POST https://spaw.co/api/v1/phone/bulk \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"phones": [
"+1 415 555 0142",
"(415) 555-0143"
],
"country": "US",
"webhook_url": "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": 733,
"status": "queued",
"total": 2,
"duplicate_count": 0,
"processed": 0,
"valid": 0,
"invalid": 0,
"low": 0,
"medium": 0,
"high": 0,
"credits_used": 0,
"stopped_reason": null,
"cancel_requested": false,
"country": "US",
"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": 733,
"status": "processing",
"total": 2,
"duplicate_count": 0,
"processed": 1,
"valid": 1,
"invalid": 0,
"low": 0,
"medium": 0,
"high": 1,
"credits_used": 1,
"stopped_reason": null,
"cancel_requested": false,
"country": "US",
"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, 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.