Docs/API Reference/File Upload & Preview
POST/v1/verify/bulk/upload

Create 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

1

Upload

Returns 202 with a job_id in status ingesting

2

Preview (optional)

With preview=true the job waits in awaiting_confirmation with a free estimate

3

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 (multipart/form-data)
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 (application/json)
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

FieldTypeRequiredDescription
filefileOne of file / source_urlmultipart only. .csv, .tsv, .txt or .xlsx, up to 50 MB.
source_urlstringOne of file / source_urlJSON only. Public https:// URL the API downloads the file from (50 MB max; private, loopback and internal addresses are blocked).
namestringOptionalFriendly job name (max 200 characters).
email_columnstring | integerOptionalHeader name (case-insensitive) or 0-based index of the email column.
column_mappingobject (JSON)OptionalAlternative to email_column, e.g. {"email": "Work Email"}. In multipart, send it as a JSON string.
previewbooleanOptionaltrue: stop after ingestion with a free list-health preview; nothing is charged until you confirm.
callback_urlstringOptionalPublic 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.email always 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 .txt file 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_mapping that 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 with invalid_column_mapping, and a file with no recognisable email column fails with email_column_not_found.

Response

202 Accepted
{
  "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

POST/v1/verify/bulk/{job_id}/preview

Upload 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
curl -X POST https://api.rocketverifier.com/v1/verify/bulk/Jx3nq0V8cW1sT5bYk2dHfA/preview \
  -H "Authorization: Bearer YOUR_API_KEY"
200 OK
{
  "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 fieldMeaning
total_emailsUnique addresses that would be verified (and charged)
duplicates_removedRows dropped by the case-insensitive de-duplication
free_checksCounts of syntax_invalid, no_mx, disposable, role_based, suppressed, free_provider, resolved_without_smtp and needs_smtp
smtp_samplesize and per-classification counts of the free SMTP sample
estimated_countsEstimated rows per classification (valid, invalid, risky, catch_all, disposable, unknown)
estimated_by_statusThe same estimate as deliverable / undeliverable / risky / unknown
estimated_bounce_rateEstimated share of invalid addresses (0-1)
undeliverable_share, risky_share, unknown_shareEstimated shares (0-1)
credits_neededCredits confirm will charge (1 per unique address)
estimated_credits_after_refundscredits_needed minus the expected unknown results, which are refunded at completion
confidencehigh (no SMTP needed, or 100+ sampled), medium, or low (no sample)
computed_atWhen the preview was computed
  • While the job is still ingesting the call returns 202 with preview: null; poll again.
  • ?refresh=true recomputes the preview of an awaiting_confirmation job (202 with refreshing: true).
  • Jobs uploaded without preview=true return 409 preview_unavailable.
  • Addresses already on your suppression lists count as undeliverable in the estimate.

Confirm and Start

POST/v1/verify/bulk/{job_id}/confirm

Charges 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.

200 OK
{
  "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.

Node.js
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

ingesting

The file is being read and de-duplicated; nothing charged yet

awaiting_confirmation

Preview ready; waiting for /confirm (or cancel)

pending → processing → completed

Charged and verifying, exactly like a JSON bulk job

failed / cancelled

Ingestion failed, or cancelled before or during verification

Related