Skip to content

Batches

Batches verify a list of addresses in one submission instead of one POST /api/verify call per address. The batch routes accept either your dashboard JWT access token or a pm_ API key as the bearer, so a server-side integration can clean a list with the same key it uses for single verification. A key must carry the email:verify:bulk scope for the submit routes and results:read for the reads.

Submit a batch

POST /api/batches takes a list of emails, normalizes and deduplicates them server-side, and queues one job per unique valid address. A syntax-invalid or duplicate address never becomes a job; it is counted in syntax_errors or duplicates on the response instead.

curl -X POST https://api.pricklymails.com/api/batches \
  -H "Authorization: Bearer your_jwt_access_token" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Newsletter list", "emails": ["a@example.com", "b@example.com"] }'

The response is 202 Accepted with a Location header pointing at the poll URL and a Retry-After hint:

{
    "batch_uuid": "6f1c...",
    "status": "processing",
    "total_jobs": 2,
    "syntax_errors": 0,
    "duplicates": 0,
    "estimated_cost": 14
}

Your plan governs which checks may be requested; omit it and the batch defaults to every check your plan allows, SMTP included. Deselect any check to narrow the batch and its cost; a disallowed check is a 403. The total cost is reserved from your wallet atomically alongside the batch and its jobs, so a wallet shortfall (402) or a disallowed check (403) never leaves partial jobs behind. A single request accepts up to 4,096 emails; 400 is returned for an oversized list, or when every address was rejected.

Stream a larger list

Set final: false on the submit above to keep the batch's intake open, then append further chunks:

curl -X POST https://api.pricklymails.com/api/batches/6f1c.../emails \
  -H "Authorization: Bearer your_jwt_access_token" \
  -H "Content-Type: application/json" \
  -d '{ "emails": ["c@example.com"], "final": true }'

Each chunk (up to 4,096 emails, the same per-request cap) reserves and queues its jobs the same way as the first, growing the batch's progress.total, up to the domain's 1,000,000-email ceiling. Cross-chunk deduplication is not performed: a chunk only dedupes against itself. Set final: true on the last chunk to close intake; the batch then completes as soon as every job (old and new) finishes. Returns 409 if intake is already closed or the batch is terminal, and 404 for a batch you do not own.

List and poll

GET /api/batches lists your batches, newest first, paginated (limit default 20, cap 96; offset).

GET /api/batches/{uuid} returns one batch's status, progress, and aggregated results:

{
    "uuid": "6f1c...",
    "status": "processing",
    "progress": { "total": 2, "processed": 1, "succeeded": 1, "failed": 0, "skipped": 0, "percent_complete": 50 },
    "results": { "valid": 1, "invalid": 0, "risky": 0, "unknown": 0, "duplicates": 0, "syntax_errors": 0 },
    "created_at": "2026-08-01T12:00:00Z",
    "updated_at": "2026-08-01T12:00:04Z"
}

GET /api/batches/{uuid}/results returns the per-email results, submission order first, paginated the same way. A batch uuid owned by someone else returns the same 404 as an unknown one. Each row:

{
    "uuid": "6f1c...",
    "status": "completed",
    "score": 76,
    "verdict": "risky",
    "recommendation": "send",
    "custom_id": "crm-42"
}

verdict and score populate once the job is terminal. recommendation is the action to take for list cleaning, derived from the verdict: remove (invalid, disposable, typo, dead domain), send (valid, or a mainstream accept-all provider you send to normally though it cannot be confirmed), or review (catch-all, role, full mailbox, or an inconclusive probe). custom_id echoes back whatever you supplied for that address at submit (null if none), so you can map a result to your own row without trusting order.