# Bulk jobs

> Run up to 50,000 phone numbers and e-mails through any service: free estimate, JSON or CSV input, progress, paginated results and CSV/NDJSON downloads.

Canonical: https://mobilevalidate.com/docs/bulk-jobs · Last updated: 2026-09-25

A bulk job checks a list of up to 50,000 phone numbers and/or e-mail addresses against one or more services, in the background. You create it with `POST /v1/jobs` and follow its progress. When it is done you page through the results or download the whole file as CSV or NDJSON. Jobs use the lower bulk price and can run services that are offered in bulk only.

## How do I estimate a job first?

`POST /v1/jobs/estimate` takes the same body as a job and returns counts and the maximum cost, without checking anything:

```bash
curl https://api.mobilevalidate.com/v1/jobs/estimate \
  -H "Authorization: Bearer $MOBILEVALIDATE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"numbers": ["+447700900001", "+447700900002", "+447700900002", "+447700900003"], "checks": ["signal", "imessage"]}'
```

```json
{"object": "estimate", "total": 4, "valid": 3, "invalid": 0, "duplicate": 1, "cached": 0, "unsupported": 0,
 "suppressed": 0, "checks": ["signal.registered", "imessage.registered"], "checks_total": 8, "billable_max": 0,
 "max_cost": {"amount": "0", "currency": "USD"}}
```

`total`, `valid`, `invalid`, `duplicate`, `unsupported` and `suppressed` count rows (`unsupported` is currently always 0; unsupported countries show up per check in the results). `checks_total`, `cached` and `billable_max` count checks (rows × services). `billable_max` and `max_cost` are the worst case, and `max_cost` is exactly what the job reserves, so you can pass it as the job's `max_cost`. They include the checks counted in `cached`: a cached answer is free if it is still fresh when the job reaches it, so the final charge is often lower, and the unused part of the reservation is released. (Test keys always estimate zero.)

## How do I create a job?

Send JSON with `numbers` and/or `emails` (up to 50,000 in total) and `checks`:

```bash
curl https://api.mobilevalidate.com/v1/jobs \
  -H "Authorization: Bearer $MOBILEVALIDATE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: signal-demo-001" \
  -d '{"numbers": ["+447700900001", "+447700900002", "+447700900003"], "checks": ["signal", "imessage", "rcs"]}'
```

Or upload a CSV file as `multipart/form-data`. The `file` field holds the CSV. The optional fields are `checks` (comma-separated), `default_country`, `max_age`, `max_cost` (a decimal amount) and `webhook_endpoint_id`:

```bash title="Upload a CSV (leads.csv in the current directory)"
curl https://api.mobilevalidate.com/v1/jobs \
  -H "Authorization: Bearer $MOBILEVALIDATE_API_KEY" \
  -F file=@leads.csv -F checks=whatsapp,carrier,email -F default_country=GB
```

A `phone`, `number`, `msisdn`, `mobile`, `telephone` or `e164` column feeds `numbers`, and an `email` (or `e-mail`, `mail`) column feeds `emails`. A row with both contributes to both; empty cells are skipped. If the file contains e-mail addresses, request at least one e-mail check (as above), otherwise the job is refused with `400 invalid_request`; the same applies to numbers and phone checks. Without a recognised header, the first column is used, and cells containing `@` are treated as e-mails. The request body may be up to 4 MB. The API answers `201` with the job object and a `Location: /v1/jobs/{id}` header. Numbers are numbered first (rows 1..n), then e-mails.

## What limits apply to a job?

- Up to **50,000 identifiers** (numbers + e-mails) per job. Above that: `400 too_many_numbers`.
- Up to **20 checks** per request.
- Up to **100,000 checks per job** (rows × applicable checks, counting every submitted row, invalid ones included). Above that: `400 invalid_request`, "Too many checks: numbers and e-mails × applicable checks must not exceed 100,000 per job. Send fewer identifiers or fewer checks."
- The account's daily cap counts **identifiers** (valid numbers and e-mails), not checks.
- Anti-enumeration rules apply as for lookups: requests that look like sequential number ranges or generated e-mail lists are rejected (see [rate limits and abuse](/docs/rate-limits-and-abuse)).

All of these, plus `max_cost` and your balance, are checked before anything is stored.

## How do I follow progress?

`GET /v1/jobs/{id}` returns the job, and `?wait=30` long-polls until the job finishes or 30 seconds pass. Live jobs move from `queued` to `running` and end as `completed` or `cancelled`. Test-mode jobs are `completed` as soon as they are created. `preflight`, `merging` and `failed` are reserved for future use, so handle them without breaking.

```json
{"object": "job", "id": "job_0VWF4DPMNNZ10QNau3Pe", "status": "completed", "livemode": false,
 "checks": ["signal.registered", "imessage.registered", "rcs.registered"],
 "created_at": "2026-09-25T14:23:23.624Z", "completed_at": "2026-09-25T14:23:23.630Z",
 "progress": {"total": 3, "checks_total": 9, "done": 9, "conclusive": 6, "non_billable": 9},
 "eta_seconds": null,
 "cost": {"estimated_max": {"amount": "0", "currency": "USD"}, "reserved": {"amount": "0", "currency": "USD"},
          "charged": {"amount": "0", "currency": "USD"}, "released": {"amount": "0", "currency": "USD"}},
 "retention_days": 30}
```

`progress.total` counts rows. `checks_total`, `done`, `conclusive` and `non_billable` count checks. `cost` shows the reserved, charged and released amounts. To be notified instead of polling, subscribe a [webhook](/docs/webhooks) to `job.completed` (sent for live jobs; `job.failed` and `job.progress` are subscribable, delivery is rolling out).

## How do I page through results?

`GET /v1/jobs/{id}/results?limit=100&after=<cursor>` returns `{object: "list", data, has_more, next_cursor}`. `limit` defaults to 100, with a maximum of 1,000. Each item is one **row**, with all its services in `checks`, in the same shape as a [lookup result](/docs/lookups). A row is never split across pages. The input is masked (`+44770*****01`), and `e164` holds the normalized number.

Filters: `registered=true|false|null` and `status=<status>` apply to one service per row, chosen by `service=<code or alias>` (default: the job's first check). With a filter, only rows of that service's input type are returned. For example, `?service=signal&registered=true` lists the numbers with a Signal account. A bad cursor returns `400 invalid_cursor`.

## How do I download the results?

`GET /v1/jobs/{id}/download?format=csv` or `?format=ndjson` streams the whole file with a `Content-Disposition: attachment` header. There is one line per row, in row order. The first columns describe the row's primary service (its first requested check of that kind), except `billed`, which is `true` when any check on the row was billed:

`row_no, input_masked, e164, country, number_status, service, status, registered, business, checked_at, cached, billed, kind, email, email_status`

Then, for each service, the columns `<service>.status`, `<service>.registered` (yes/no services), `<service>.<attribute>` for every attribute, and `<service>.billed`. They are empty for rows of the other kind. A real CSV line from a test job with `whatsapp`, `spam` and `email`:

```text
row_no,input_masked,e164,country,number_status,service,status,registered,business,checked_at,cached,billed,kind,email,email_status,whatsapp.registered.status,whatsapp.registered.registered,whatsapp.registered.billed,number.spam.status,number.spam.risk_level,number.spam.risk_score,number.spam.reason_regulator,number.spam.reason_government,number.spam.reason_community,number.spam.reason_unassigned,number.spam.voip_range,number.spam.top_category,number.spam.first_seen,number.spam.last_seen,number.spam.sources,number.spam.billed,email.valid.status,email.valid.registered,email.valid.billed
1,"'+44770*****01",+447700900001,GB,valid,whatsapp.registered,completed,true,,2026-09-25T14:28:15.993Z,false,false,phone,,,completed,true,false,completed,high,95,true,false,true,false,false,robocall,2025-11,2026-08,2,false,,,
```

Values that a spreadsheet could read as a formula, such as a masked number starting with `+`, get a leading apostrophe. Booleans are `true`/`false`, and integers are plain numbers. In NDJSON, empty cells are `null` and integers are JSON numbers.

## How do I cancel a job or delete its data?

`DELETE /v1/jobs/{id}` does one of two things:

- **Running job:** cancels it. Checks not yet sent for processing are released and never billed. Checks already in progress finish, are stored and are billed if conclusive, because they can't be recalled. The job ends as `cancelled`.
- **Finished job:** purges its per-row results now, instead of waiting for the retention period. The job object remains with `purged_at` set. Afterwards `GET /v1/jobs/{id}/results` returns an empty list (`data: []`), and download requests return `404 not_found` with the message "Results for this job were purged."

Job results are kept for your account's retention setting (30 days by default) and then deleted automatically.

## Frequently asked questions

### Which services can run in a bulk job?

Every active service, including those offered in bulk only (for example signal, imessage, rcs, gmail). The real-time flag does not matter for jobs.

### Is the estimate free?

Yes. POST /v1/jobs/estimate only validates and counts; it checks nothing, stores nothing and charges nothing.

### What happens to results after a job finishes?

They are kept for your account's retention period (30 days by default) and then deleted. You can delete them earlier with DELETE /v1/jobs/{id}.

### If I cancel a running job, am I charged for anything?

Checks not yet sent for processing are released and never billed. Checks already in progress finish, are stored and are billed if conclusive, because they cannot be recalled.
