Skip to content

API reference/bulk

Queue a bulk verification run

POST/api/v1/email/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 a run costs at most the `credits_upper_bound` the preview answered and usually less.

Queues up to 100,000 addresses and processes them in the background in chunks, with every chunk's mailbox probes sent concurrently. Creation answers 202 with the job's id and status. Poll GET /api/v1/email/bulk/{jobId} for progress, then download the result CSV from GET /api/v1/email/bulk/{jobId}/results. Jobs and their files are kept for 30 days.

Rows repeating an address already in the list are verified once and counted in duplicate_count. Addresses at a domain already known to be catch-all, addresses on your suppression list, and addresses your account reported delivered recently answer without a probe. A run that exhausts the balance stops with stopped_reason: "insufficient_credits" and keeps everything verified so far.

Completion webhook

With a webhook_url, the finish (completed, failed, or cancelled) is POSTed there as { "event": "bulk_email_job.finished", "job": { "id", "status", "total", "processed", "deliverable", "risky", "undeliverable", "credits_used", "stopped_reason", "finished_at" } }. The X-Spaw-Signature-V2 header carries two comma-separated elements, t={unix seconds},v2={lowercase hex}: the hex is the HMAC-SHA256 of the string v2:{t}:{raw body}, keyed with webhook_secret, which this response returns once — and again only on an idempotent replay of the same request. Check the timestamp as well as the digest and refuse anything more than five minutes from your own clock: a signature over the body alone never goes stale, so a captured delivery could be posted back at you a year later and still verify. Connection errors and 5xx answers are retried twice with a short backoff, three attempts in all.

$elements = [];

foreach (explode(',', (string) $request->header('X-Spaw-Signature-V2')) as $element) {
    $pair = explode('=', trim($element), 2);

    if (count($pair) === 2) {
        $elements[$pair[0]] = $pair[1];
    }
}

$timestamp = $elements['t'] ?? '';

abort_unless(ctype_digit($timestamp) && abs(time() - (int) $timestamp) <= 300, 401);

$expected = hash_hmac('sha256', "v2:{$timestamp}:".$request->getContent(), $secret);

abort_unless(hash_equals($expected, $elements['v2'] ?? ''), 401);

The original X-Spaw-Signature — the same HMAC over the raw body alone, with no timestamp — is still sent beside it until 1 March 2027, so a receiver written against it keeps working while you move over. After that only the timestamped header is sent.

Retrying safely

Send an Idempotency-Key header (1 to 128 printable characters, unique per request — a UUID stored with whatever triggered the run) and creation becomes safe to retry after a timeout or a dropped connection. A repeat with the same key and the same addresses and webhook URL answers the job the first attempt created, as 200 instead of 202, with the same body including webhook_secret and the header Idempotent-Replayed: true; nothing is queued twice. The same key with a different request answers 409 IDEMPOTENCY_KEY_REUSED. Keys are scoped to your account and live as long as the job (30 days).

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-KeyheaderstringOptional. Makes the creation safe to retry; a repeat with the same key replays the job it created instead of queueing another. 1 to 128 characters.

Request body

field type description
emailsstring[]1 to 100,000 addresses; each item at most 254 characters. 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 POST when the job finishes. The response includes the signing secret — shown at creation and on an idempotent replay of the same request, never otherwise. At most 2,048 characters.

Example request

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

response = requests.post(
    'https://spaw.co/api/v1/email/bulk',
    headers={'Authorization': 'Bearer sk_live_…'},
    json={
        'emails': [
            '[email protected]',
            '[email protected]'
        ],
        'webhook_url': 'https://www.example.com/hooks/spaw'
    },
)
result = response.json()
$ch = curl_init('https://spaw.co/api/v1/email/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([
        'emails' => [
            '[email protected]',
            '[email protected]'
        ],
        'webhook_url' => 'https://www.example.com/hooks/spaw'
    ]),
]);
$result = json_decode(curl_exec($ch), true);
from spaw import Client

client = Client('sk_live_…')
result = client.create_bulk_job([
    '[email protected]',
    '[email protected]'
], webhook_url='https://www.example.com/hooks/spaw')
import Spaw from 'spaw';

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

$spaw = new Client('sk_live_…');
$result = $spaw->createBulkJob([
    '[email protected]',
    '[email protected]'
], webhookUrl: 'https://www.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,
            "deliverable": 0,
            "risky": 0,
            "undeliverable": 0,
            "credits_used": 0,
            "stopped_reason": null,
            "cancel_requested": false,
            "webhook_status": null,
            "created_at": "2026-09-03T10: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,
            "deliverable": 1,
            "risky": 0,
            "undeliverable": 0,
            "credits_used": 1,
            "stopped_reason": null,
            "cancel_requested": false,
            "webhook_status": null,
            "created_at": "2026-09-03T10:12:44+00:00",
            "finished_at": null,
            "webhook_secret": "8fJ2…40 characters…Qk1"
        }
    }
}

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