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"}'
// Node 18+ (global fetch)
const res = await fetch("https://api.boundstone.io/v1/verify/phone", {
method: "POST",
headers: {
"authorization": "Bearer bs_live_YOUR_KEY",
"content-type": "application/json",
},
body: JSON.stringify({ phone: "+1 650 447 2983" }),
});
const data = await res.json();
console.log(data.valid, data.checks.not_performed);
# pip install requests
import requests
res = requests.post(
"https://api.boundstone.io/v1/verify/phone",
headers={"authorization": "Bearer bs_live_YOUR_KEY"},
json={"phone": "+1 650 447 2983"},
)
data = res.json()
print(data["valid"], data["checks"]["not_performed"])
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
func main() {
body, _ := json.Marshal(map[string]string{"phone": "+1 650 447 2983"})
req, _ := http.NewRequest("POST", "https://api.boundstone.io/v1/verify/phone", bytes.NewReader(body))
req.Header.Set("authorization", "Bearer bs_live_YOUR_KEY")
req.Header.Set("content-type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var out map[string]any
json.NewDecoder(res.Body).Decode(&out)
fmt.Println(out["valid"], out["checks"])
}
<?php
$ch = curl_init("https://api.boundstone.io/v1/verify/phone");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"authorization: Bearer bs_live_YOUR_KEY",
"content-type: application/json",
],
CURLOPT_POSTFIELDS => json_encode(["phone" => "+1 650 447 2983"]),
]);
$data = json_decode(curl_exec($ch), true);
echo $data["valid"] ? "valid" : "invalid";
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.
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.
| Language | Install | Package |
|---|---|---|
| Python 3.8+ | pip install boundstone | pypi.org/project/boundstone |
| Node 18+ · TypeScript types · ESM + CJS | npm install boundstone | npmjs.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 dashboard | What it is |
|---|---|
| Try it | Verify a single phone or email inline — a real, metered call (1 credit), result shown with the same checks.not_performed honesty line |
| Bulk jobs | Upload a CSV or paste values — same job engine, caps and per-row refunds as the API |
| Usage & Credit history | Calls and credits by endpoint, plus an itemized ledger — every credit in and out, with a running balance |
| Your data | Export 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
| Field | Type | Notes |
|---|---|---|
| phone | string, required | Any human format — E.164 comes back normalized |
| country | string, optional | Two-letter default region for national-format inputs, e.g. "US", "AU" |
| hlr | boolean, optional | Set 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
| Field | Type | Notes |
|---|---|---|
| string, 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
| Field | Type | Notes |
|---|---|---|
| ip | string, required | IPv4 or IPv6 — canonical form comes back in normalized |
Returns validity, version (4/6), and classification — public 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
| Endpoint | Does |
|---|---|
| POST /v1/bulk/phone · /v1/bulk/email · /v1/bulk/ip | Submit — returns job_id, 202 |
| GET /v1/bulk/:id | Status: queued → running → done |
| GET /v1/bulk/:id/results.csv | Results 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
| Endpoint | Does |
|---|---|
| GET /v1/account | Email, plan, credit balance (authed) |
| GET /v1/account/ledger | Itemized credit history — every grant, spend and refund, newest first (authed) |
| GET /v1/health | Liveness ping, no auth |
| GET /v1/status | Public 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
| Status | Code | Meaning |
|---|---|---|
| 400 | bad_request | Missing or malformed input — never charged |
| 401 | unauthorized / key_revoked | Bad or revoked key |
| 402 | insufficient_credits | Out of credits — explicit, never silent overage |
| 404 | not_found | That endpoint doesn't exist — we won't pretend it does |
| 429 | rate_limited | Sliding-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.