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.