> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tracklysms.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Validate Phone Numbers (Batch)

> Validate up to 500 phone numbers in a single request for list cleaning.

Validate a batch of phone numbers in one request. Per-number errors (bad format, provider failure, rate-limit timeout) are returned **in-band** — a single bad phone never fails the whole batch. Results are returned in submission order so you can zip them back into your original list.

Same data source, pricing, and caching as [`POST /v1/phone/validate`](/api-reference/phone/validate): LRN + DNC, \$0.003 per non-cached lookup, permanent per-account cache.

## Pricing & Billing

Each non-cached number in the batch is billed at **\$0.003**. Once a phone has been validated for your account, repeat lookups are free **indefinitely** (`cost: 0`) — pass `force=true` at the **request level** (single top-level boolean, applies to every phone in the batch) to bypass the cache and re-validate at \$0.003 per number (rare; LRN data only changes on carrier porting). Charges accrue throughout the month and are rolled into a single Stripe charge on the 1st of the following month.

Your account must have an active payment method. Requests from accounts with `payment_failed` or `suspended` billing status return `402 Payment Required`.

## Size Limit

Up to **500 phones per request**. This matches our LRN provider's rate ceiling (50 req/s) — 500 cache-miss numbers take roughly 10 seconds worst-case. For larger lists, chunk client-side or fire multiple requests in parallel. The permanent per-account cache makes re-validation of the same numbers effectively free after the first pass.

## Request

<ParamField body="phones" type="string[]" required>
  Array of phone numbers to validate. E.164 format recommended (e.g., `+14155551234`). Common US formats are normalized automatically. Duplicate numbers within a batch are deduplicated by the cache layer — each unique E.164 is only paid once per account, ever.
</ParamField>

<ParamField body="force" type="boolean" default="false">
  Request-level flag — applies to every phone in the batch. Bypasses the per-account cache and runs a fresh paid lookup for each number. Defaults to `false`. Must be a strict boolean — string `"true"` is rejected with `invalid_force`.
</ParamField>

## Response

<ResponseField name="results" type="array">
  Per-number result in submission order. Each entry is either a successful validation (same schema as the single-number endpoint) or an inline error object with `phone`, `error`, `code`, and `message` fields.
</ResponseField>

<ResponseField name="summary" type="object">
  Aggregated counts and total cost for the batch.

  <Expandable title="summary fields">
    <ResponseField name="total" type="integer">Total phones submitted.</ResponseField>
    <ResponseField name="ok" type="integer">Valid mobile, not on DNC — safe to send.</ResponseField>
    <ResponseField name="unreachable" type="integer">Landline or VoIP — cannot receive SMS.</ResponseField>
    <ResponseField name="invalid" type="integer">Not a valid number per the carrier network.</ResponseField>
    <ResponseField name="risky" type="integer">Valid mobile but on DNC list.</ResponseField>
    <ResponseField name="unknown" type="integer">Line type could not be determined.</ResponseField>
    <ResponseField name="errors" type="integer">Per-number errors (invalid format, provider failure, rate-limited).</ResponseField>
    <ResponseField name="cached" type="integer">Served from the per-account cache (\$0 billed). Cache is permanent per (account, phone) once validated.</ResponseField>
    <ResponseField name="total_cost" type="number">Sum of USD billed for this batch.</ResponseField>
  </Expandable>
</ResponseField>

<RequestExample>
  ```bash cURL theme={null}
  curl -X POST https://api.tracklysms.com/api/v1/phone/validate/batch \
    -H "X-Api-Key: your_api_key" \
    -H "Content-Type: application/json" \
    -d '{
      "phones": [
        "+14155551234",
        "+13103059808",
        "not-a-phone",
        "+15551234567"
      ]
    }'
  ```

  ```python Python theme={null}
  import requests

  response = requests.post(
      "https://api.tracklysms.com/api/v1/phone/validate/batch",
      headers={
          "X-Api-Key": "your_api_key",
          "Content-Type": "application/json",
      },
      json={"phones": phone_list},
  )

  payload = response.json()
  clean = [
      r["phone"]
      for r in payload["results"]
      if r.get("disposition") == "ok"
  ]
  print(f"Keeping {len(clean)} of {payload['summary']['total']}; "
        f"cost ${payload['summary']['total_cost']}")
  ```

  ```javascript Node.js theme={null}
  const axios = require('axios');

  const { data } = await axios.post(
    'https://api.tracklysms.com/api/v1/phone/validate/batch',
    { phones: phoneList },
    {
      headers: {
        'X-Api-Key': 'your_api_key',
        'Content-Type': 'application/json',
      },
    }
  );

  const clean = data.results
    .filter((r) => r.disposition === 'ok')
    .map((r) => r.phone);
  console.log(`Keeping ${clean.length} of ${data.summary.total}; ` +
              `cost $${data.summary.total_cost}`);
  ```
</RequestExample>

<ResponseExample>
  ```json 200 OK theme={null}
  {
    "results": [
      {
        "phone": "+14155551234",
        "valid": true,
        "line_type": "mobile",
        "carrier": "Verizon Wireless",
        "carrier_raw": "CELLCO PARTNERSHIP DBA VERIZON",
        "ported": false,
        "country": "US",
        "state": "California",
        "city": "San Francisco",
        "on_dnc": false,
        "disposition": "ok",
        "cost": 0.003,
        "cached": false
      },
      {
        "phone": "+13103059808",
        "valid": true,
        "line_type": "fixed line",
        "carrier": "Frontier",
        "carrier_raw": "FRONTIER CALIFORNIA, INC.",
        "ported": true,
        "country": "US",
        "state": "California",
        "city": "Santa Monica",
        "on_dnc": false,
        "disposition": "unreachable",
        "cost": 0.003,
        "cached": false
      },
      {
        "phone": "not-a-phone",
        "error": "validation_error",
        "code": "invalid_phone",
        "message": "invalid phone number format"
      },
      {
        "phone": "+15551234567",
        "valid": true,
        "line_type": "mobile",
        "carrier": "T-Mobile",
        "carrier_raw": "T-MOBILE USA, INC.",
        "ported": false,
        "country": "US",
        "state": "",
        "city": "",
        "on_dnc": false,
        "disposition": "ok",
        "cost": 0,
        "cached": true
      }
    ],
    "summary": {
      "total": 4,
      "ok": 2,
      "unreachable": 1,
      "invalid": 0,
      "risky": 0,
      "unknown": 0,
      "errors": 1,
      "cached": 1,
      "total_cost": 0.006
    }
  }
  ```

  ```json 400 Bad Request theme={null}
  {
    "error": "validation_error",
    "code": "missing_phones",
    "message": "phones array is required and must be non-empty"
  }
  ```

  ```json 402 Payment Required theme={null}
  {
    "error": "billing_not_enabled",
    "code": "no_payment_method",
    "message": "An active payment method is required to use phone validation."
  }
  ```

  ```json 413 Payload Too Large theme={null}
  {
    "error": "validation_error",
    "code": "too_many_phones",
    "message": "Maximum 500 phones per request"
  }
  ```
</ResponseExample>

## Error Codes

| Code                  | HTTP | Description                                                                |
| --------------------- | ---- | -------------------------------------------------------------------------- |
| `missing_phones`      | 400  | `phones` array missing or empty                                            |
| `invalid_force`       | 400  | `force` is not a strict boolean (string `"true"` / int `1` are rejected)   |
| `too_many_phones`     | 413  | More than 500 phones in one request                                        |
| `invalid_credentials` | 401  | Missing or invalid `X-Api-Key`                                             |
| `account_suspended`   | 403  | Your account is suspended. Resolve outstanding billing or contact support. |
| `rate_limited`        | 429  | Request throttled; retry with exponential backoff after the window resets. |
| `no_billing_config`   | 402  | Account has no billing configuration                                       |
| `no_payment_method`   | 402  | No Stripe customer / card on file                                          |
| `payment_failed`      | 402  | Most recent charge failed                                                  |
| `suspended`           | 402  | Account is suspended                                                       |

### Per-number inline error codes

These appear inside `results[]`, not at the top level:

| Code             | Description                                                        |
| ---------------- | ------------------------------------------------------------------ |
| `invalid_phone`  | The number could not be parsed as E.164                            |
| `provider_error` | Upstream lookup failed for this specific number                    |
| `rate_limited`   | Batch hit our LRN provider safety ceiling; retry this subset later |

## Behavior Notes

<Info>
  **Rate limiting.** We self-cap provider calls at 50/s globally across all requests. Beyond that, we honor the provider's own `x-ratelimit-*` headers and back off on 429. For very large cleans, stagger parallel requests or rely on the per-account cache to absorb most of the load after the first pass.
</Info>

<Tip>
  **Cost control.** Cache hits cost nothing. Re-validating the same list any time later is effectively free — the cache is permanent per (account, phone) once a number has been validated. If you process the same customer base daily, your cost tends toward "new numbers today × \$0.003" rather than "total list × \$0.003".
</Tip>
