# How to validate phone numbers in Clay

_2026-08-14 · Boundstone (https://boundstone.io/blog/validate-phone-numbers-clay)_


If you run outbound out of [Clay](https://www.clay.com), your phone numbers arrive from a dozen places — an Apollo export, a ZoomInfo enrichment, a scraped list, a form fill — and they arrive in a dozen shapes. Before those rows reach a dialer or an SMS send, it is worth knowing which ones are structurally real and what kind of line each one is.

There is no Boundstone app in Clay's catalog. There does not need to be one: Clay's **HTTP API enrichment column** calls any JSON endpoint, and that is the whole integration.

## First, the honest part about Clay's own option

Clay ships a native mobile-validation action, and it is powered by **Trestle**. If you have not set up any phone validation at all, that is the path of least resistance and it works well.

So why add an HTTP column instead? One reason, and it is not "we are more accurate" — we have not published accuracy numbers, and we will not until [benchmark № 001](/benchmarks) does. The reason is **disagreement**. Line type and carrier are exactly the fields where vendors diverge, and on a list you are about to spend rep hours dialing, a second opinion on the rows that matter is cheap. What makes a disagreement useful rather than confusing is knowing what each side actually checked — which is the thing this API is built around.

## Add the enrichment column

In your Clay table: **Add enrichment → HTTP API**.

| Field | Value |
|---|---|
| Method | `POST` |
| URL | `https://api.boundstone.io/v1/verify/phone` |
| Headers | `Authorization: Bearer bs_live_YOUR_KEY`<br>`Content-Type: application/json` |
| Body | `{"phone": "{{Phone}}", "country": "US"}` |

Replace `{{Phone}}` with your table's phone column and `bs_live_YOUR_KEY` with a key from the [dashboard](https://app.boundstone.io). `country` is optional — it is the default region used when a number arrives without a `+` prefix, which in practice is most CRM exports.

A response looks like this — this is a real one, not a paraphrase:

```json
{
  "input": "+16504472983",
  "valid": true,
  "e164": "+16504472983",
  "country": "US",
  "line_type": "fixed_line_or_mobile",
  "national_format": "(650) 447-2983",
  "allocation": { "allocated": true, "reason": null, "snapshot": "2026-07-30" },
  "checks": {
    "performed": ["format", "region", "line_type_metadata", "allocation"],
    "not_performed": ["carrier_lookup", "ported_status", "hlr_liveness"]
  }
}
```

⚠️ `country` is worth setting. Without it, a number that arrives without a `+` prefix — which is most CRM exports — has no default region to parse against, and comes back `valid: false` with everything null. That is the API declining to guess, not a failure, but it will quietly gut a column if you skip it.

## Map the fields you will actually sort on

Three are worth pulling into their own Clay columns:

- **`e164`** — write this back over your original phone column. E.164 is the one format every dialer, CRM and SMS platform agrees on, and normalizing at enrichment time removes an entire class of downstream import failure. (`national_format` is there too if you display numbers to reps.)
- **`line_type`** — the field you route on. See below.
- **`allocation.allocated`** — whether the number's block is actually assigned in the North American Numbering Plan at `allocation.snapshot`. This is how obviously fabricated numbers in a purchased list get caught: a well-formed number in an unassigned block is still well-formed, and still cannot ring. `allocation.reason` tells you which kind — `unallocated_block` or `reserved_fictional` (the 555-01XX range). Outside the NANP, `allocation` is `null` rather than implying coverage that does not exist.

## Routing by line type

Clay conditional runs make this straightforward — filter the view, then act on each segment:

| `line_type` | What it means | Reasonable action |
|---|---|---|
| `mobile` | Cellular line | SMS and voice both available |
| `fixed_line` | Landline | Voice only — do not spend on SMS |
| `voip` | VoIP-designated range | Dial with lower expectation; common for spam traps and throwaways |
| `toll_free` | 8XX | Usually a company main line, not a person |
| `fixed_line_or_mobile` | **The plan cannot tell** | See below |

`fixed_line_or_mobile` is not a bug or a gap we are working around. In much of the North American plan, mobile and landline numbers are drawn from the same ranges, so the metadata genuinely cannot separate them — and a vendor that returns a confident `mobile` for those rows is guessing. If that split matters to your send, it needs the live network, not better metadata.

## When you need to know a number is live

Everything above is metadata. It tells you a number is well-formed, in an allocated block, and what kind of line it is. It does **not** tell you the number is assigned to someone, still in service, or that a call will connect.

For that, add `"hlr": true` to the request body. That runs a live [HLR lookup](/blog/what-is-hlr-lookup) against the carrier network and returns carrier, ported status and reachability. It costs 5 credits instead of 1, it is never run on the free tier, and when the network declines to answer, the row is **refunded rather than guessed at**.

Use it as a second pass on the segment you care about — the rows that survived the cheap filters — rather than across the whole table.

## What this does not do

Worth stating plainly, because a Clay column that quietly implies more than it checked is worse than no column:

- **It is not DNC or TCPA scrubbing.** Validation removes numbers that cannot be real; it says nothing about whether you are permitted to call one. That needs a registry vendor — see [DNC vs phone validation](/blog/dnc-vs-phone-validation).
- **It is not identity matching.** Nothing here confirms a number belongs to the person on the row.
- **Without `hlr:true`, it is not liveness — and not carrier either.** Every response carries `not_performed: ["carrier_lookup", "ported_status", "hlr_liveness"]`, which is the point: you can read what was skipped instead of inferring it.

## The short version

Clay needs no Boundstone app: an HTTP enrichment column pointed at `/v1/verify/phone` gives you E.164 normalization, allocation-aware validity and line type on every row, for one credit each. Set `country` so unprefixed numbers parse. Route SMS on `mobile`, keep `fixed_line` on voice, treat `fixed_line_or_mobile` as genuinely unknown, and spend the paid HLR dip only on the segment that earns it. Every response tells you which checks ran and which did not — so what you push back into your table is a fact, not an impression.
