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.

Last updated

View as Markdown

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.

Can I upload a list without writing code?

Yes. Sign in to the dashboard and choose Bulk jobs › Upload a list:

  1. Upload a CSV or TXT file (up to 10 MB), or paste the numbers and e-mail addresses. The dashboard finds the phone and e-mail columns (you can change them), asks for a country for numbers written without +, and shows how many rows are valid, invalid or duplicates before anything runs. Excel files: save them as CSV first.
  2. Choose checks. Each check shows its bulk price per 1,000 and, where it applies, the real-time price for small groups. If a check you choose does not cover some countries in your list (for example iMessage and mainland China numbers), a warning names the check, the countries and how many numbers are affected.
  3. Free estimate and start. You see the same free estimate as POST /v1/jobs/estimate: the maximum cost, the price lines and your balance. You can set a cost cap. If your balance is too low, top up in another tab; your list stays on the page. Billing explains top-ups, the reservation and what is never charged.

The job then appears under Bulk jobs with live progress. You can cancel it while it runs, download the results as CSV (the same file and columns as the download below) and delete the results once it has finished. Dashboard jobs follow every rule on this page: the same limits, prices, anti-enumeration checks and "only conclusive answers are charged". Jobs created with your API keys are listed there too. The dashboard works in test mode as well, with the test values.

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. Prices in the examples on this page are example values; current prices are on the pricing page.

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

Runs as pasted with the public sandbox key, which answers the test values only. In the SDKs, omit the key to use MOBILEVALIDATE_API_KEY.

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"},
 "breakdown": [
   {"check": "signal.registered", "price_mode": "batch", "reason": null, "checks": 0,
    "unit_price": {"amount": "0.0005", "currency": "USD"}, "max_cost": {"amount": "0", "currency": "USD"}},
   {"check": "signal.registered", "price_mode": "batch", "reason": "country_minimum", "checks": 3,
    "unit_price": {"amount": "0.0005", "currency": "USD"}, "max_cost": {"amount": "0", "currency": "USD"},
    "countries": ["GB"], "country_checks": {"GB": 3},
    "minimum_checks": 500, "minimum_charge": {"amount": "0.25", "currency": "USD"},
    "note": "Bulk-only check: each country with fewer than 500 numbers is billed at least $0.25 (the price of 500 checks) when it returns at least one answer. Countries with no answers are free."},
   {"check": "imessage.registered", "price_mode": "batch", "reason": null, "checks": 0,
    "unit_price": {"amount": "0.0008", "currency": "USD"}, "max_cost": {"amount": "0", "currency": "USD"}},
   {"check": "imessage.registered", "price_mode": "batch", "reason": "country_minimum", "checks": 3,
    "unit_price": {"amount": "0.0008", "currency": "USD"}, "max_cost": {"amount": "0", "currency": "USD"},
    "countries": ["GB"], "country_checks": {"GB": 3},
    "minimum_checks": 500, "minimum_charge": {"amount": "0.4", "currency": "USD"},
    "note": "Bulk-only check: each country with fewer than 500 numbers is billed at least $0.40 (the price of 500 checks) when it returns at least one answer. Countries with no answers are free."}],
 "unsupported_checks": []}

(Real output with the public sandbox key, so every max_cost is 0.) Signal and iMessage are offered in bulk only, and three UK numbers are fewer than the 500 per country these checks are billed at least, so each check's numbers sit on a country_minimum line and its batch line shows "checks": 0. With a live key, the country_minimum lines would show max_cost $0.25 and $0.40, and the estimate's max_cost $0.65.

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: unit_price shows your live price, max_cost is 0.)

breakdown lists, for each check, how many checks are priced at which unit price. The max_cost values of all lines add up to the estimate's max_cost. Every check has one batch line, whose checks can be 0 when all of its numbers are counted on another line. A second line with "price_mode": "realtime" and "reason": "small_batch" appears when part of the list is too small for the bulk price, and a line with "reason": "country_minimum" appears when a bulk-only check has countries with fewer numbers than its minimum per country (both explained below).

unsupported_checks lists checks that will not run because the check does not cover some countries of the list, for example imessage.registered for mainland China (+86) numbers. Those checks answer unsupported_country, are free and are not part of billable_max or max_cost; every other check and country runs as usual. The list is empty when every check covers every country. GET /v1/services shows each check's gaps per mode in unsupported_countries.

JSON
"unsupported_checks": [{"check": "imessage.registered", "checks": 120, "countries": ["CN"], "country_checks": {"CN": 120}}]

When does a job use the real-time price?

WhatsApp checks (whatsapp, whatsapp.business) get the bulk price only for groups of at least 132 numbers from the same country in one job. A smaller group from one country is checked the real-time way, so its checks are priced at the service's real-time price. From 2026-11-03 the same rule applies to X checks (x) with a minimum of 1,000 numbers from the same country. This happens most often in mixed lists, where most numbers come from one country and a few come from others. For example, the estimate for 1,000 UK numbers and 20 German numbers (abridged; example prices):

JSON
{"object": "estimate", "total": 1020, "valid": 1020, "checks": ["whatsapp.registered"], "billable_max": 1020,
 "max_cost": {"amount": "0.33", "currency": "USD"},
 "breakdown": [
   {"check": "whatsapp.registered", "price_mode": "batch", "reason": null, "checks": 1000,
    "unit_price": {"amount": "0.00025", "currency": "USD"}, "max_cost": {"amount": "0.25", "currency": "USD"}},
   {"check": "whatsapp.registered", "price_mode": "realtime", "reason": "small_batch", "checks": 20,
    "unit_price": {"amount": "0.004", "currency": "USD"}, "max_cost": {"amount": "0.08", "currency": "USD"},
    "countries": ["DE"], "batch_minimum": 132}]}
  • The rule is applied per check and per country. The country is the one the number belongs to, so for example Jersey, Guernsey and Isle of Man numbers (+44) count separately from the UK.
  • Only valid, distinct numbers count. Invalid, duplicate and suppressed rows are never checked.
  • The price of every check is fixed when the job is created and shown in the job's cost.breakdown. As always, you pay only for conclusive answers. Unknown results in a small group are free.
  • Other checks are not affected. They use the bulk price for any number of rows.

To get the bulk price for every number, send at least 132 numbers per country in one job, for example by collecting a day's numbers into one job instead of sending many small jobs. If you mostly check a few numbers at a time, real-time lookups cost the same and answer straight away.

Is there a minimum per country for bulk-only checks?

Yes. Checks that are offered in bulk only (they have no real-time price, for example rcs, signal, imessage, line or the US/Canada premium carrier lookup; the pricing table marks them "Bulk only") are processed one country at a time, and each country has a minimum charge: the price of 500 checks of that service. From 2026-11-03 the minimum for the TikTok, LINE, LinkedIn, Snapchat and Messenger checks is the price of 2,000 checks, because each country file of these checks has a higher fixed cost; read 500 as 2,000 for those checks below.

  • The rule. For each country group (the numbers of one country for one check in one job), the charge is the larger of conclusive answers × bulk price and 500 × bulk price, and it applies only when the group returns at least one conclusive answer. A group whose answers are all unknown or unsupported costs nothing.
  • Example. The RCS check costs $0.0005 (as of 2026-10-17), so the minimum is $0.25 per country. One number from one country that answers costs $0.25. 600 numbers from one country that all answer cost 600 × $0.0005 = $0.30. 494 numbers spread over 55 countries cost at most 55 × $0.25 = $13.75.
  • Countries with 500 or more numbers are subject to the same minimum, but it only changes the price when fewer than 500 of their numbers return an answer.
  • No surprises. The estimate moves the numbers of every country with fewer than 500 numbers into a separate country_minimum line whose max_cost is the minimum for each of those countries, so max_cost (and the reservation) already covers it. When the job finishes you are charged by the rule above and the rest of the reservation is released.
  • Checks with a real-time price (for example WhatsApp or Telegram) never have this minimum; small WhatsApp groups use the rule in the previous section instead.
  • E-mail checks offered in bulk only (for example the Gmail, Outlook, Yahoo, LinkedIn and X account checks) have the same minimum, but per job instead of per country, because e-mail addresses have no country: all addresses for that check in one job form one group, billed at least the price of 500 checks when the group returns at least one answer. The estimate shows it as a country_minimum line with an empty countries list.
  • Daily limit for small groups. In rare cases a small group of a bulk-only check returns unknown with reason: "SMALL_GROUP_LIMIT" because small groups of that check are limited per day. These results are not billed; send the numbers again later or together with more numbers from the same country.

For example, the estimate for 3 German, 2 French and 1,000 UK numbers with the RCS check (abridged):

JSON
{"object": "estimate", "total": 1005, "valid": 1005, "checks": ["rcs.registered"], "billable_max": 1005,
 "max_cost": {"amount": "1", "currency": "USD"},
 "breakdown": [
   {"check": "rcs.registered", "price_mode": "batch", "reason": null, "checks": 1000,
    "unit_price": {"amount": "0.0005", "currency": "USD"}, "max_cost": {"amount": "0.5", "currency": "USD"}},
   {"check": "rcs.registered", "price_mode": "batch", "reason": "country_minimum", "checks": 5,
    "unit_price": {"amount": "0.0005", "currency": "USD"}, "max_cost": {"amount": "0.5", "currency": "USD"},
    "countries": ["DE", "FR"], "country_checks": {"DE": 3, "FR": 2},
    "minimum_checks": 500, "minimum_charge": {"amount": "0.25", "currency": "USD"},
    "note": "Bulk-only check: each country with fewer than 500 numbers is billed at least $0.25 (the price of 500 checks) when it returns at least one answer. Countries with no answers are free."}]}

In a country_minimum line, checks counts the numbers in the listed countries (they are not on the batch line), country_checks gives the numbers per country, minimum_charge is the minimum per country and max_cost is minimum_charge × the number of countries. To pay the bulk price for every number, send at least 500 numbers per country, or collect small lists by country into fewer, larger jobs. The dashboard's review step lists the affected countries before you start a job.

How do I create a job?

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

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

Runs as pasted with the public sandbox key, which answers the test values only. In the SDKs, omit the key to use MOBILEVALIDATE_API_KEY.

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:

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

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_0VYRlRU80X0OQO0aC3GU", "status": "completed", "livemode": false,
 "checks": ["signal.registered", "imessage.registered", "rcs.registered"],
 "created_at": "2026-10-18T23:28:07.536Z", "completed_at": "2026-10-18T23:28:07.538Z",
 "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"},
          "breakdown": [
            {"check": "signal.registered", "price_mode": "batch", "reason": null, "checks": 0,
             "unit_price": {"amount": "0.0005", "currency": "USD"}, "max_cost": {"amount": "0", "currency": "USD"}},
            {"check": "signal.registered", "price_mode": "batch", "reason": "country_minimum", "checks": 3,
             "unit_price": {"amount": "0.0005", "currency": "USD"}, "max_cost": {"amount": "0", "currency": "USD"},
             "countries": ["GB"], "country_checks": {"GB": 3},
             "minimum_checks": 500, "minimum_charge": {"amount": "0.25", "currency": "USD"}, "note": "Bulk-only check: …"},
            {"check": "imessage.registered", "price_mode": "batch", "reason": null, "checks": 0, "…": "…"},
            {"check": "imessage.registered", "price_mode": "batch", "reason": "country_minimum", "checks": 3, "…": "…"},
            {"check": "rcs.registered", "price_mode": "batch", "reason": null, "checks": 0, "…": "…"},
            {"check": "rcs.registered", "price_mode": "batch", "reason": "country_minimum", "checks": 3, "…": "…"}]},
 "retention_days": 30,
 "estimate": {"total": 3, "valid": 3, "invalid": 0, "duplicate": 0, "cached": 0, "unsupported": 0, "suppressed": 0,
              "checks": ["signal.registered", "imessage.registered", "rcs.registered"], "checks_total": 9,
              "billable_max": 0, "max_cost": {"amount": "0", "currency": "USD"},
              "breakdown": ["… the same lines as cost.breakdown …"], "unsupported_checks": []}}

(Real sandbox output from POST /v1/jobs, abridged where marked "…".) progress.total counts rows. checks_total, done, conclusive and non_billable count checks. cost shows the reserved, charged and released amounts, and cost.breakdown the price lines fixed when the job was created (the same shape as the estimate's breakdown). The response to POST /v1/jobs also carries the job's estimate (the same object as POST /v1/jobs/estimate returns, without object); GET /v1/jobs/{id} does not. To be notified instead of polling, subscribe a webhook 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. 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.

Why are some WhatsApp checks in my job priced at the real-time price?

The bulk price needs at least 132 numbers from the same country for that check in one job. A smaller group, such as 20 numbers from one country in a mixed list, is checked the real-time way and priced at the real-time price. The estimate shows these checks in breakdown with reason small_batch. Send at least 132 numbers per country in one job to get the bulk price for all of them. From 2026-11-03 the same rule applies to X checks with a minimum of 1,000 numbers per country.

Why does my estimate show a minimum per country?

Checks offered in bulk only, such as rcs, signal or imessage, are billed at least the price of 500 checks for each country in a job that returns at least one answer, for example $0.25 per country for a $0.0005 check (from 2026-11-03 the price of 2,000 checks for tiktok, line, linkedin, snapchat and messenger). A country with no answers is free. The estimate lists the countries with fewer than 500 numbers in breakdown with reason country_minimum. Send at least 500 numbers per country, or group your lists by country, to pay the bulk price per number.

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.