# Real-time lookups

> Check up to 100 phone numbers and e-mails in one request: multi-check, waiting and polling, the results shape, caching, max_cost and idempotency.

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

`POST /v1/lookup` checks 1–100 phone numbers and/or e-mail addresses against one or more services and waits up to 30 seconds for the answers. It returns `200` when every answer is ready. Otherwise it returns `202` with `status: "pending"` and a URL to poll. Use it for real-time decisions, such as a sign-up form or an OTP send. For lists, use [bulk jobs](/docs/bulk-jobs).

## What goes in the request?

```json
{
  "numbers": ["+447700900001", "07700 900002"],
  "emails": ["registered@test.mobilevalidate.com"],
  "checks": ["whatsapp", "telegram", "email"],
  "default_country": "GB",
  "max_age": 604800,
  "wait": 10,
  "max_cost": {"amount": "0.05", "currency": "USD"},
  "webhook_endpoint_id": "we_…",
  "metadata": {"crm_id": "C-1042"}
}
```

| Field | Rules |
|---|---|
| `numbers`, `emails` | At least one; 1–100 identifiers in total. Numbers in any format; national formats need `default_country` |
| `checks` | Service codes or aliases, up to 20 (default `["whatsapp"]`). Phone checks run on `numbers`, e-mail checks on `emails` |
| `default_country` | ISO 3166-1 alpha-2, e.g. `GB` |
| `max_age` | Seconds; accept a cached answer up to this age. `0` forces a fresh, billed check |
| `wait` | 0–30 seconds to hold the request open (default 10) |
| `max_cost` | Refuse with `402 cost_limit_exceeded` if the maximum possible cost is higher |
| `webhook_endpoint_id` | A verified endpoint that receives `lookup.completed` |
| `metadata` | Up to 20 string keys, values up to 500 characters; echoed back |

Unknown fields are rejected (`400 invalid_request`), so typos don't pass silently. Every check must have identifiers of its type: `"checks": ["email"]` with only `numbers` is refused with `param: "checks[0]"`. Numbers × applicable checks may not exceed 2,000 per lookup.

## How do multi-check requests work?

Every identifier gets one result per requested service of its input type. Numbers get phone checks and e-mails get e-mail checks. If you send e-mails without any e-mail check (or numbers without a phone check), the request is refused with `param: "checks"`. That way you never pay for a request that couldn't answer. `whatsapp.registered` and `whatsapp.business` together collapse into `whatsapp.business`, because it answers both questions.

Some services are **bulk only**. On this endpoint they return `403 service_disabled`: "The check 'signal.registered' is available in bulk jobs only (POST /v1/jobs)." The [services reference](/docs/services) marks them. `GET /v1/services` returns `modes` for each service.

## What does the response look like?

Real test-mode output for `{"numbers": ["+447700900001", "+447700900002", "+447700900003"], "checks": ["whatsapp", "telegram", "carrier"]}` (first item, then the totals):

```json
{
  "object": "lookup",
  "id": "lkp_0VWF4BLkr4ltR4n4dawy",
  "status": "completed",
  "livemode": false,
  "created_at": "2026-09-25T14:23:15.712Z",
  "results": [
    {
      "kind": "phone",
      "input": "+447700900001",
      "e164": "+447700900001",
      "country": "GB",
      "number_status": "valid",
      "checks": {
        "whatsapp.registered": {"service": "whatsapp.registered", "status": "completed", "registered": true, "attributes": null, "confidence": "high", "confidence_score": 0.99, "checked_at": "2026-09-25T14:23:15.717Z", "cached": false, "age_seconds": 0, "billed": false, "reason": null, "poll_after_ms": null},
        "telegram.registered": {"service": "telegram.registered", "status": "completed", "registered": true, "attributes": null, "confidence": "high", "confidence_score": 0.99, "checked_at": "2026-09-25T14:23:15.717Z", "cached": false, "age_seconds": 0, "billed": false, "reason": null, "poll_after_ms": null},
        "network.carrier": {"service": "network.carrier", "status": "completed", "registered": true, "attributes": {"line_type": "mobile", "carrier": "Test Carrier", "country": "GB"}, "confidence": "high", "confidence_score": 0.99, "checked_at": "2026-09-25T14:23:15.717Z", "cached": false, "age_seconds": 0, "billed": false, "reason": null, "poll_after_ms": null}
      },
      "whatsapp": {"service": "whatsapp.registered", "status": "completed", "registered": true, "confidence": "high", "confidence_score": 0.99, "checked_at": "2026-09-25T14:23:15.717Z", "cached": false, "age_seconds": 0, "billed": false, "reason": null, "poll_after_ms": null},
      "test": true
    }
  ],
  "summary": {
    "total": 3, "registered": 1, "not_registered": 1, "unknown": 1, "pending": 0, "invalid": 0, "suppressed": 0,
    "by_service": {
      "whatsapp.registered": {"completed": 2, "registered": 1, "not_registered": 1, "unknown": 1, "pending": 0},
      "telegram.registered": {"completed": 2, "registered": 1, "not_registered": 1, "unknown": 1, "pending": 0},
      "network.carrier": {"completed": 1, "registered": 1, "not_registered": 0, "unknown": 2, "pending": 0}
    }
  },
  "billing": {"billed_units": 0, "cost": {"amount": "0", "currency": "USD"}, "balance_after": {"amount": "9.97907", "currency": "USD"}},
  "next": null,
  "request_id": "req_0VWF4BLivGQN76TzQcXl"
}
```

## How do I read a result item?

- **Rows** come in input order: numbers first, then e-mails. `kind` is `phone` or `email`; treat a missing `kind` as `phone`.
- **Phone rows** have `e164`, `country` and `number_status` (`valid`, `invalid_number`, `duplicate`, `suppressed`). E-mail rows have `email` and `email_status` instead (see [e-mail checks](/docs/emails)).
- **`checks`** holds one result per service, keyed by code, in request order. It is missing for invalid, duplicate and suppressed rows, which are never checked or billed.
- **`registered`** is `false` only for a conclusive negative. `null` means not conclusive. Data services (carrier, spam) put their answer in `attributes` and set `registered: true` when data was found.
- **`whatsapp`** is a compatibility copy of the WhatsApp result in the original v1 shape, with `business` added when `whatsapp.business` was requested. It appears only when a `whatsapp.*` check was requested. New code should read `checks`.
- **`summary`** counts rows for the first requested service of each row's kind. `summary.by_service` gives the counts per service. For data services, "registered" means data found.
- **`billing`** shows the checks billed in this request, their cost and your balance afterwards.

Clients should ignore fields they don't recognise and accept new enum values.

## How do waiting and polling work?

The request waits up to `wait` seconds (default 10, maximum 30). If some answers are still outstanding, you get `202` with a `Location` header and:

```json
"status": "pending",
"next": {"poll_url": "/v1/lookups/lkp_0VWF4BNZB4tgTHFOXsUy", "poll_after_ms": 2000}
```

Poll `GET /v1/lookups/{id}?wait=30`. This is a long poll: it returns as soon as everything is done, or after 30 seconds. Pending checks carry `status: "pending"` and `poll_after_ms`. With `wait: 0` the request returns straight away, which suits webhook-driven flows. Test number `+447700900004` stays pending for about 5 seconds, so you can try this out. When you read a lookup later, identifiers in `input` are masked (for example `+44770*****04`).

## How do caching, costs and retries work?

- **Cache.** Answers are cached per account, number (or address) and service. A request within `max_age` (default: each service's freshness window) is answered from cache with `cached: true`, the answer's age in `age_seconds`, and `billed: false`. `max_age: 0` forces a fresh check, which is billed and rate limited.
- **Reservation.** Before checking, the API reserves the maximum possible cost of the request. Each check is then settled when its answer arrives. Inconclusive checks (unknown, unsupported country, timeout, invalid, duplicate) are released, and you're not charged for them. If the reservation doesn't fit your balance, you get `402 insufficient_balance`. If it exceeds `max_cost`, you get `402 cost_limit_exceeded`.
- **Idempotency.** Send `Idempotency-Key: <1–255 printable ASCII characters, no spaces>` on any POST. The same key with the same body within 24 hours replays the stored response with `Idempotent-Replayed: true`. The same key with a different body gets `409 idempotency_key_reused`. A retry while the first request is still running gets `409 idempotency_request_in_progress` (retryable). Keys are scoped to your account.

## Frequently asked questions

### What happens if an answer takes longer than my wait time?

The API returns 202 with status pending and a next.poll_url. Poll GET /v1/lookups/{id}?wait=30, or register a webhook for lookup.completed.

### Is a pending result billed?

Only once it becomes conclusive. The price is reserved when you send the request and settled per check; unknown, unsupported, invalid and duplicate results are released and free.

### Can I send the same request twice safely?

Yes, with an Idempotency-Key header. The same key and body within 24 hours replays the first response (Idempotent-Replayed: true) instead of checking again.

### Why is the whatsapp object missing from some results?

The top-level whatsapp object is a compatibility copy that appears only when a whatsapp.* check was requested. Always read checks for new code.
