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 MarkdownA 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:
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"]}'// npm install mobilevalidate · Node.js 20+ · save as check.mjs and run: node check.mjs
import { MobileValidate } from "mobilevalidate";
// Omit apiKey to read MOBILEVALIDATE_API_KEY from the environment.
const mv = new MobileValidate({ apiKey: "mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym" });
const { data, error } = await mv.jobs.estimate({
numbers: [
"+447700900001",
"+447700900002",
"+447700900002",
"+447700900003",
],
checks: ["signal", "imessage"],
});
if (error) console.error(error.code, error.message);
else console.dir(data, { depth: null });// Node.js 18+, Deno, Bun or the browser console (ES module: save as .mjs or use "type": "module").
const res = await fetch("https://api.mobilevalidate.com/v1/jobs/estimate", {
method: "POST",
headers: {
Authorization: "Bearer mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym",
"Content-Type": "application/json",
},
body: JSON.stringify({
numbers: [
"+447700900001",
"+447700900002",
"+447700900002",
"+447700900003",
],
checks: ["signal", "imessage"],
}),
});
console.log(res.status, res.headers.get("x-request-id"));
console.dir(await res.json(), { depth: null });# pip install mobilevalidate-sdk
from mobilevalidate import MobileValidate
# Omit api_key to read MOBILEVALIDATE_API_KEY from the environment.
mv = MobileValidate(api_key="mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym")
result = mv.jobs.estimate(
numbers=[
"+447700900001",
"+447700900002",
"+447700900002",
"+447700900003",
],
checks=["signal", "imessage"],
)
print(result)<?php
$ch = curl_init('https://api.mobilevalidate.com/v1/jobs/estimate');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'numbers' => [
'+447700900001',
'+447700900002',
'+447700900002',
'+447700900003',
],
'checks' => ['signal', 'imessage'],
]),
]);
$response = curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_RESPONSE_CODE), PHP_EOL, $response, PHP_EOL;package main
import (
"fmt"
"io"
"net/http"
"strings"
)
func main() {
body := strings.NewReader(`{"numbers":["+447700900001","+447700900002","+447700900002","+447700900003"],"checks":["signal","imessage"]}`)
req, err := http.NewRequest("POST", "https://api.mobilevalidate.com/v1/jobs/estimate", body)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym")
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}require "net/http"
require "json"
uri = URI("https://api.mobilevalidate.com/v1/jobs/estimate")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym"
req["Content-Type"] = "application/json"
req.body = JSON.generate({
"numbers" => [
"+447700900001",
"+447700900002",
"+447700900002",
"+447700900003"
],
"checks" => ["signal", "imessage"]
})
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code, res.bodyRuns as pasted with the public sandbox key, which answers the test values only. In the SDKs, omit the key to use MOBILEVALIDATE_API_KEY.
{"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:
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"]}'// npm install mobilevalidate · Node.js 20+ · save as check.mjs and run: node check.mjs
import { MobileValidate } from "mobilevalidate";
// Omit apiKey to read MOBILEVALIDATE_API_KEY from the environment.
const mv = new MobileValidate({ apiKey: "mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym" });
const { data, error } = await mv.jobs.create({
numbers: ["+447700900001", "+447700900002", "+447700900003"],
checks: ["signal", "imessage", "rcs"],
});
if (error) console.error(error.code, error.message);
else console.dir(data, { depth: null });// Node.js 18+, Deno, Bun or the browser console (ES module: save as .mjs or use "type": "module").
const res = await fetch("https://api.mobilevalidate.com/v1/jobs", {
method: "POST",
headers: {
Authorization: "Bearer mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym",
"Content-Type": "application/json",
},
body: JSON.stringify({
numbers: ["+447700900001", "+447700900002", "+447700900003"],
checks: ["signal", "imessage", "rcs"],
}),
});
console.log(res.status, res.headers.get("x-request-id"));
console.dir(await res.json(), { depth: null });# pip install mobilevalidate-sdk
from mobilevalidate import MobileValidate
# Omit api_key to read MOBILEVALIDATE_API_KEY from the environment.
mv = MobileValidate(api_key="mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym")
result = mv.jobs.create(
numbers=["+447700900001", "+447700900002", "+447700900003"],
checks=["signal", "imessage", "rcs"],
)
print(result)<?php
$ch = curl_init('https://api.mobilevalidate.com/v1/jobs');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'numbers' => ['+447700900001', '+447700900002', '+447700900003'],
'checks' => ['signal', 'imessage', 'rcs'],
]),
]);
$response = curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_RESPONSE_CODE), PHP_EOL, $response, PHP_EOL;package main
import (
"fmt"
"io"
"net/http"
"strings"
)
func main() {
body := strings.NewReader(`{"numbers":["+447700900001","+447700900002","+447700900003"],"checks":["signal","imessage","rcs"]}`)
req, err := http.NewRequest("POST", "https://api.mobilevalidate.com/v1/jobs", body)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym")
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}require "net/http"
require "json"
uri = URI("https://api.mobilevalidate.com/v1/jobs")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym"
req["Content-Type"] = "application/json"
req.body = JSON.generate({
"numbers" => ["+447700900001", "+447700900002", "+447700900003"],
"checks" => ["signal", "imessage", "rcs"]
})
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code, res.bodyRuns 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:
curl https://api.mobilevalidate.com/v1/jobs \
-H "Authorization: Bearer $MOBILEVALIDATE_API_KEY" \
-F [email protected] -F checks=whatsapp,carrier,email -F default_country=GBA 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.
{"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 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®istered=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:
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_atset. AfterwardsGET /v1/jobs/{id}/resultsreturns an empty list (data: []), and download requests return404 not_foundwith 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.

