Documentation

First verified number in 60 seconds.

Contents

Quickstart

Want to see a real result before you get a key? The homepage demo runs the same zero-cost phone, email and IP checks live — no sign-up, no card (rate-limited per IP).

1. Get an API key from the dashboard — sign in with an email, keys are shown once. Sign-in is a magic link — no password to leak, and the free tier needs no card.

2. Make the call — pick your language:

curl -X POST https://api.boundstone.io/v1/verify/phone \
  -H "authorization: Bearer bs_live_YOUR_KEY" \
  -H "content-type: application/json" \
  -d '{"phone": "+1 650 447 2983"}'

3. Read the response — including what we didn't check:

{
  "input": "+1 650 447 2983",
  "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"]
  },
  "credits_charged": 1
}

allocation is why valid means more here than "well-formed". A phone library can tell you the digits parse and the area code is real; it cannot tell you whether a carrier actually holds that exchange. So a library alone accepts unallocated NPA-NXX blocks and the reserved 555-01XX range used in films — numbers that survive a cheap validation pass and then get dialled. We check allocation against the NANPA central-office data and return the snapshot date, because allocation changes monthly and an undated claim isn't verifiable. allocation is null for non-NANP numbers: we hold no allocation data for other numbering plans, and saying nothing beats implying coverage. When allocated is false, valid is false too, with reason set to unallocated_block or reserved_fictional.

checks.not_performed is load-bearing. Most vendors return a confident-looking verdict whether or not they actually checked. We list both sides so your code — and your audit trail — knows exactly what a result is based on.

Try it live

The quickstart without the terminal: pick an endpoint, run a real check, read the JSON — including checks.not_performed. This runs the keyless demo endpoint (the free tier's zero-cost checks, capped per IP per hour); the snippet underneath is the same call with a real key.

Runs the keyless demo endpoint — the same zero-cost checks the free tier runs, capped per IP per hour. No key leaves this page; the full endpoint is the snippet below with your own bs_live_ key.

Run a check — the live JSON and the honesty contract land here.
curl -X POST https://api.boundstone.io/v1/verify/phone \
  -H "authorization: Bearer bs_live_YOUR_KEY" \
  -H "content-type: application/json" \
  -d '{"phone":"+1 650 447 2983"}'

Official SDKs

Typed clients for Python and Node, both zero-dependency (standard library / native fetch). The honesty contract is first-class in each: checks.performed and checks.not_performed are typed fields, and every result keeps the full API response on .raw.

LanguageInstallPackage
Python 3.8+pip install boundstonepypi.org/project/boundstone
Node 18+ · TypeScript types · ESM + CJSnpm install boundstonenpmjs.com/package/boundstone
# Python
from boundstone import Boundstone
bs = Boundstone("bs_live_YOUR_KEY")
r = bs.verify_email("you@example.com")
print(r.mx_found, r.checks.not_performed)
// Node — ESM shown; const { Boundstone } = require("boundstone") works too
import Boundstone from "boundstone";
const bs = new Boundstone("bs_live_YOUR_KEY");
const r = await bs.verifyEmail("you@example.com");
console.log(r.mx_found, r.checks.not_performed);

Non-2xx responses raise a typed BoundstoneError carrying the API's status, code and plain-language message. Both clients are intentionally thin — same endpoints, same tariff, same honesty contract as raw HTTP.

Dashboard — no code required

Everything the API does, the dashboard does without a terminal:

In the dashboardWhat it is
Try itVerify a single phone or email inline — a real, metered call (1 credit), result shown with the same checks.not_performed honesty line
Bulk jobsUpload a CSV or paste values — same job engine, caps and per-row refunds as the API
Usage & Credit historyCalls and credits by endpoint, plus an itemized ledger — every credit in and out, with a running balance
Your dataExport everything as JSON, or delete the account and all its data — self-serve, immediate

Authentication

Bearer key in the authorization header: Bearer bs_live_…. Keys are created and revoked in the dashboard (max 5 active). We store only a hash — lose a key, mint a new one.

POST /v1/verify/phone

FieldTypeNotes
phonestring, requiredAny human format — E.164 comes back normalized
countrystring, optionalTwo-letter default region for national-format inputs, e.g. "US", "AU"
hlrboolean, optionalSet true to add a live HLR dip (carrier, ported status, reachability). Paid plans only; 5 credits. Default false

Default (1 credit): validity, E.164, country, line type (200+ countries, libphonenumber metadata tier). Carrier, ported status and HLR liveness report as not_performed — that's the honest baseline, not a limitation you have to guess at.

With "hlr": true (5 credits, paid plans): we run a real HLR network dip and add an hlr object — status (connected/absent/invalid/undetermined), reachable, current_carrier, mccmnc, ported, roaming, data_source — and carrier_lookup, ported_status and hlr_liveness move into checks.performed. If the network can't determine the number, the dip abstains (status: "undetermined"), credits_charged is 0 and the 5 credits are refunded — you never pay for a dip that didn't answer. Free plans get an honest 402 hlr_paid_only.

curl -X POST "https://api.boundstone.io/v1/verify/phone" \
  -H "authorization: Bearer bs_live_YOUR_KEY" \
  -H "content-type: application/json" \
  -d '{"phone": "+14156226819", "hlr": true}'

POST /v1/verify/email

FieldTypeNotes
emailstring, required

Returns syntax validity, domain, live MX records, A-record fallback, disposable-domain flag, role-account flag (admin@, billing@…), free-provider flag. SMTP mailbox and catch-all probing are not_performed — we won't guess deliverability we didn't test. 1 credit.

POST /v1/verify/ip

FieldTypeNotes
ipstring, requiredIPv4 or IPv6 — canonical form comes back in normalized

Returns validity, version (4/6), and classificationpublic vs private, loopback, link_local, multicast, reserved, documentation, shared, unique_local and other IANA special ranges — plus is_public and is_bogon booleans. This is the zero-cost validation tier: syntax and routability, useful for catching bogon/reserved source IPs at signup. Geolocation, ASN, hosting/datacenter and proxy/VPN/Tor detection are not_performed — they ship with licensed data (benchmark № 003) and will appear in checks.performed the day they're real, never guessed. 1 credit.

Bulk CSV

curl -X POST "https://api.boundstone.io/v1/bulk/phone?country=US" \
  -H "authorization: Bearer bs_live_YOUR_KEY" \
  -H "content-type: text/csv" \
  --data-binary @numbers.csv

First CSV column is read; a header row is auto-detected. Free plan: 250 rows per job. Paid: 10,000. Credits (1/row) are reserved when the job is accepted and refunded for any rows that fail to process. Small jobs usually finish before your first poll. Results — and your uploaded CSV — are kept for 30 days after completion, then deleted (retention schedule: privacy policy); after that the results endpoint returns an honest 410 and the job's metadata remains.

Bulk live HLR — add ?hlr=true to POST /v1/bulk/phone to run a live HLR dip on every row (carrier, ported status, reachability), exactly like the single-call opt-in. Paid plans only; 5 credits/row reserved up front; capped at 1,000 rows/job (each row is a real network dip). Per row: an unusable number is charged the 1-credit metadata rate (4 refunded), a dip the network can't determine is fully refunded, a determinate answer keeps the 5. The results CSV gains hlr_status,reachable,carrier,mccmnc,ported columns. Because dips take time, HLR jobs drain in throttled batches — poll /v1/bulk/:id (the job object carries hlr:true) or set a webhook.

curl -X POST "https://api.boundstone.io/v1/bulk/phone?hlr=true" \
  -H "authorization: Bearer bs_live_YOUR_KEY" \
  -H "content-type: text/csv" \
  --data-binary @numbers.csv
EndpointDoes
POST /v1/bulk/phone · /v1/bulk/email · /v1/bulk/ipSubmit — returns job_id, 202
GET /v1/bulk/:idStatus: queued → running → done
GET /v1/bulk/:id/results.csvResults as CSV once done

Webhooks

Register an endpoint in the dashboard and skip polling: when a bulk job finishes, we POST a signed bulk.completed event to it.

POST https://your-app.com/hooks/boundstone
boundstone-event: bulk.completed
boundstone-signature: t=1718900000,v1=<hex>

{
  "event": "bulk.completed",
  "job_id": "…",
  "kind": "phone",
  "status": "done",          // or "error"
  "total": 250,
  "processed": 250,
  "error": null,
  "results_url": "https://api.boundstone.io/v1/bulk/…/results.csv",
  "occurred_at": 1718900000
}

Verify every delivery. The boundstone-signature header is t=<unix ts>,v1=<hex>, where the hex is HMAC-SHA256 of <ts>.<raw request body> keyed by the endpoint's signing secret (shown once when you add it):

import crypto from "node:crypto";

function verify(secret, header, rawBody) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto.createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

Reject if it doesn't match or the timestamp is stale. Delivery retries with exponential backoff (30s up to 1h, six attempts) on any non-2xx or timeout; every attempt shows in the dashboard. Endpoints must be https:// and can't point at private hosts. Up to 3 per account.

Account & status

EndpointDoes
GET /v1/accountEmail, plan, credit balance (authed)
GET /v1/account/ledgerItemized credit history — every grant, spend and refund, newest first (authed)
GET /v1/healthLiveness ping, no auth
GET /v1/statusPublic uptime feed — same data as the status page, CORS-open

Rate limits

Per key, sliding 10-second window: free 15 · Starter 60 · Growth 120 · Scale 240. Hitting the limit returns 429 with a plain message; nothing queues silently. Every verify response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds) headers so you can pace yourself without guessing; the 429 adds Retry-After.

Idempotency

Retries shouldn't cost you twice. Send an Idempotency-Key header (any unique string, up to 255 chars) on a verify or bulk-submit call, and if a request with that key already succeeded, we replay the exact same response — with an Idempotent-Replayed: true header — and don't charge again (a retried bulk submit replays the original job_id instead of creating a second job). Only successful responses are remembered; a 4xx retries normally. Keys expire after 24 hours. Use a fresh key per distinct request (a UUID per attempt-group is typical).

curl -X POST https://api.boundstone.io/v1/verify/email \
  -H "authorization: Bearer bs_live_YOUR_KEY" \
  -H "idempotency-key: 9f2c1e7a-...-once" \
  -H "content-type: application/json" \
  -d '{"email":"name@company.com"}'

Errors

StatusCodeMeaning
400bad_requestMissing or malformed input — never charged
401unauthorized / key_revokedBad or revoked key
402insufficient_creditsOut of credits — explicit, never silent overage
404not_foundThat endpoint doesn't exist — we won't pretend it does
429rate_limitedSliding-window limit hit

Every error is JSON: {"error": "code", "message": "what happened, in words"}.

MCP connector

Plug Boundstone straight into Claude, Claude Code, or any MCP client — same tools, same credits, same honesty contract as the REST API.

# claude.ai → Settings → Connectors → Add custom connector:
https://api.boundstone.io/mcp/bs_live_YOUR_KEY

# Claude Code (header auth — prefer this where supported):
claude mcp add --transport http boundstone https://api.boundstone.io/mcp \
  -H "authorization: Bearer bs_live_YOUR_KEY"

Tools: verify_phone, verify_email, verify_ip (1 credit each), account (free). The key-in-URL form exists because connector UIs can't set headers — treat that URL like the key it contains, and revoke it from the dashboard if it leaks.

The honesty contract

Three rules the API keeps, machine-checkably: (1) every verification response lists checks.performed and checks.not_performed; (2) capabilities we haven't built return explicit errors instead of fabricated verdicts; (3) accuracy claims live in the numbered public benchmarks, not in adjectives. If a response ever violates these, that's a bug — tell us and we'll fix it in public.

The full contract is machine-readable: an OpenAPI 3.1 spec covering every endpoint (the honesty fields included) — generate a client, import it to Postman, or hand it to your agent.