/v1/verify/bulk/uploadCreate a bulk job straight from a spreadsheet or text file, or from a file at a public HTTPS URL. The file is ingested asynchronously; you can stop after ingestion for a free List Health Preview (estimated bounce rate and credits needed) and only pay when you confirm.
How It Works
Upload
Returns 202 with a job_id in status ingesting
Preview (optional)
With preview=true the job waits in awaiting_confirmation with a free estimate
Charge & verify
Automatic after ingestion, or on /confirm; then the usual job endpoints
File types
CSV, TSV, TXT, XLSX
Max size
50 MB
Max emails
500,000 unique
Preview
Free
Credits: 1 per unique address. Addresses are trimmed and de-duplicated case-insensitively (provider aliases such as Gmail dots or +tags are kept as separate rows). Credits are charged once, when ingestion finishes, or at confirm time for preview jobs; unknown results are refunded when the job completes, as with POST /v1/verify/bulk. A non-preview upload needs at least 1 credit to start; if the balance does not cover the unique count once the file is read, the job fails with insufficient_credits and nothing is charged.
Auth: scope verify:bulk. Sandbox keys (rv_test_...) cannot upload files (403 sandbox_key_restricted); use POST /v1/verify/bulk with @sandbox.rocketverifier.com addresses instead. The optional Idempotency-Key header returns the original response for a retried request (24h).
Upload a File
curl -X POST https://api.rocketverifier.com/v1/verify/bulk/upload \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: 9b2e7c1a-4f0d-4d3e-8a61-0c5f2b7e9d14" \
-F "file=@contacts.csv" \
-F "name=Q4 newsletter" \
-F "email_column=Work Email" \
-F "preview=true" \
-F "callback_url=https://yoursite.com/hooks/rocketverifier-job"Or Import From a URL
curl -X POST https://api.rocketverifier.com/v1/verify/bulk/upload \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source_url": "https://files.example.com/exports/contacts.xlsx",
"name": "CRM export",
"column_mapping": { "email": "Email Address" },
"preview": false
}'Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
| file | file | One of file / source_url | multipart only. .csv, .tsv, .txt or .xlsx, up to 50 MB. |
| source_url | string | One of file / source_url | JSON only. Public https:// URL the API downloads the file from (50 MB max; private, loopback and internal addresses are blocked). |
| name | string | Optional | Friendly job name (max 200 characters). |
| email_column | string | integer | Optional | Header name (case-insensitive) or 0-based index of the email column. |
| column_mapping | object (JSON) | Optional | Alternative to email_column, e.g. {"email": "Work Email"}. In multipart, send it as a JSON string. |
| preview | boolean | Optional | true: stop after ingestion with a free list-health preview; nothing is charged until you confirm. |
| callback_url | string | Optional | Public https:// URL that receives a signed job.completed or job.failed POST for this job. |
Send exactly one of file or source_url (400 missing_file otherwise). Multipart uploads need a Content-Length header (411 without one).
Column Mapping
- An explicit
email_column/column_mapping.emailalways wins: a header name (matched case-insensitively) or a 0-based column index. - Otherwise the API picks a header that looks like an email column (
Email,E-mail,Email Address,Mail, ...), and failing that the column whose sampled cells most often look like addresses. - The header row is detected automatically; a one-column
.txtfile with one address per line needs no header. - Every original column is kept with each row (up to 200 columns), so the download can return your file with the results appended (
include_original=true, see Download Results). column_mappingthat is not valid JSON is rejected right away (400 invalid_column_mapping). A mapping that names a column the file does not have fails ingestion withinvalid_column_mapping, and a file with no recognisable email column fails withemail_column_not_found.
Response
{
"job_id": "Jx3nq0V8cW1sT5bYk2dHfA",
"status": "ingesting",
"preview": true,
"callback_secret": "whsec_4kq7...",
"status_url": "/v1/jobs/Jx3nq0V8cW1sT5bYk2dHfA",
"progress_url": "/v1/jobs/Jx3nq0V8cW1sT5bYk2dHfA/progress"
}callback_secret is returned only when you sent a callback_url, and only in this response. Errors: 400 missing_file, 400 invalid_json, 400 invalid_column_mapping, 400 unsupported_file_type, 400 empty_file, 400 invalid_source_url / invalid_callback_url, 402 insufficient_credits, 403 sandbox_key_restricted, 403 insufficient_scope, 413 file_too_large, 415 unsupported_media_type, 503 queue_unavailable.
Ingestion Failures
Problems found while reading the file move the job to failed (nothing is charged) and fire job.failed to your webhooks and the job's callback_url. GET /v1/jobs/{id} shows the message. Codes: no_emails, too_many_emails (over 500,000 unique), email_column_not_found, invalid_column_mapping, invalid_file, file_unavailable, source_unreachable (the source_url download failed) and insufficient_credits.
Free List Health Preview
/v1/verify/bulk/{job_id}/previewUpload with preview=true and the job stops in awaiting_confirmation after ingestion. The preview runs only the free checks on every row: syntax, disposable domain, role-based address, DNS/MX, duplicates and your suppression lists. It also probes a random sample of the rows that need an SMTP check, min(200, 2% of the list), and extrapolates the outcome mix. The preview is never charged, whether or not you confirm.
curl -X POST https://api.rocketverifier.com/v1/verify/bulk/Jx3nq0V8cW1sT5bYk2dHfA/preview \
-H "Authorization: Bearer YOUR_API_KEY"{
"job_id": "Jx3nq0V8cW1sT5bYk2dHfA",
"status": "awaiting_confirmation",
"preview": {
"computed_at": "2026-10-15T10:31:12.004118+00:00",
"total_emails": 48210,
"duplicates_removed": 1372,
"free_checks": {
"syntax_invalid": 212, "no_mx": 388, "disposable": 640, "role_based": 1905,
"suppressed": 57, "free_provider": 20114,
"resolved_without_smtp": 1297, "needs_smtp": 46913
},
"smtp_sample": { "size": 200, "counts": { "valid": 171, "invalid": 14, "catch_all": 11, "unknown": 4 },
"method": "random sample of SMTP-bound rows, min(200, 2%)" },
"estimated_counts": { "valid": 40109, "invalid": 3918, "catch_all": 2580, "disposable": 640, "unknown": 963 },
"estimated_by_status": { "deliverable": 40109, "undeliverable": 4558, "risky": 0, "unknown": 3543 },
"estimated_bounce_rate": 0.0813,
"undeliverable_share": 0.0945,
"risky_share": 0.0,
"unknown_share": 0.0735,
"credits_needed": 48210,
"estimated_credits_after_refunds": 47247,
"confidence": "high"
}
}preview field | Meaning |
|---|---|
| total_emails | Unique addresses that would be verified (and charged) |
| duplicates_removed | Rows dropped by the case-insensitive de-duplication |
| free_checks | Counts of syntax_invalid, no_mx, disposable, role_based, suppressed, free_provider, resolved_without_smtp and needs_smtp |
| smtp_sample | size and per-classification counts of the free SMTP sample |
| estimated_counts | Estimated rows per classification (valid, invalid, risky, catch_all, disposable, unknown) |
| estimated_by_status | The same estimate as deliverable / undeliverable / risky / unknown |
| estimated_bounce_rate | Estimated share of invalid addresses (0-1) |
| undeliverable_share, risky_share, unknown_share | Estimated shares (0-1) |
| credits_needed | Credits confirm will charge (1 per unique address) |
| estimated_credits_after_refunds | credits_needed minus the expected unknown results, which are refunded at completion |
| confidence | high (no SMTP needed, or 100+ sampled), medium, or low (no sample) |
| computed_at | When the preview was computed |
- While the job is still
ingestingthe call returns202withpreview: null; poll again. ?refresh=truerecomputes the preview of anawaiting_confirmationjob (202withrefreshing: true).- Jobs uploaded without
preview=truereturn409 preview_unavailable. - Addresses already on your suppression lists count as undeliverable in the estimate.
Confirm and Start
/v1/verify/bulk/{job_id}/confirmCharges 1 credit per unique address (preview.credits_needed) and queues verification. Safe to retry: a job is never charged twice. Not interested? Cancel it with DELETE /v1/jobs/{id} instead; nothing was charged, so nothing is refunded.
{
"job_id": "Jx3nq0V8cW1sT5bYk2dHfA",
"status": "pending",
"credits_charged": 48210,
"credits_remaining": 151790
}Errors: 402 insufficient_credits (details.required, details.available), 409 job_not_awaiting_confirmation (details.status), 404 not_found.
Per-Job Callback
When an upload sets callback_url, RocketVerifier POSTs job.completed or job.failed for that job to the URL, in addition to any account-level webhooks. The body is the same { event_id, event, timestamp, data } envelope as webhook job events, and it is signed the same way (X-RocketVerifier-Signature: t=...,v1=...) with the job's callback_secret instead of a webhook secret. X-Webhook-ID is job_callback:<job_id>. Failed deliveries follow the webhook retry schedule. The SDK signature helpers work unchanged: pass the callback secret.
import { constructWebhookEvent } from '@rocketverifier/sdk';
// raw body Buffer, X-RocketVerifier-Signature header, the callback_secret from the upload response
const event = constructWebhookEvent(req.body, req.get('X-RocketVerifier-Signature'), process.env.RV_JOB_CALLBACK_SECRET);
if (event.event === 'job.completed') {
// event.data.download_url is "/v1/jobs/<id>/download"
}Upload Job Statuses
ingestingThe file is being read and de-duplicated; nothing charged yet
awaiting_confirmationPreview ready; waiting for /confirm (or cancel)
pending → processing → completedCharged and verifying, exactly like a JSON bulk job
failed / cancelledIngestion failed, or cancelled before or during verification
