openapi: 3.0.3 info: title: Numbers Online Phone Intelligence API description: "\nPhone number parsing, validation, and inbound caller-intelligence as a supplementary signal.\n\n## Authentication\n\nGet a key via self-service signup:\n\n```\nPOST /api/v1/account/signup {}\n```\n\nThe response returns your key exactly once. Keys look like `nol_…` and are stored\nonly as a hash — we cannot recover a lost key, so save it. Send it on every request\nin one of these headers:\n\n- `Authorization: Bearer nol_YOUR_KEY`\n- `X-API-Key: nol_YOUR_KEY`\n\nKeyless endpoints (everything else requires a key): `POST /api/v1/account/signup`,\n`GET /api/health`, `GET /api/spec`, `GET /api/v1/publickey`,\n`GET /api/v1/receipts/{id}` (the unguessable id is the credential), and the MCP\nserver's public discovery methods (`initialize`, `ping`, `tools/list`).\n\n**Canonical URL form (decision 2026-06-11):** the long `/api/v1/*` paths in this\nspec are canonical. The bare `/v1/*` spelling is a tolerated rewrite alias for\nheader-less PBX URL fields only. HMAC-signed operator\ \ requests MUST use\n`/api/v1/*` — request signing canonicalizes the post-rewrite long path, so a\nsignature computed over the bare form will not verify.\n\nThe interactive \"try it\" widgets on Numbers Online call these endpoints through a\nfirst-party server-side demo proxy, so no real key is ever shipped to the browser.\nThat proxy is an internal convenience for the website only — integrate against the\ndocumented endpoints with your own key.\n\n## Which lookup do I want?\n\nSeven surfaces answer \"who/what is this number\" — they differ in direction, side\neffects, price, and how they fail. Pick by row:\n\n| endpoint | direction | what you get | side effects | price (standard tier) | limit & failure mode | response auth |\n|---|---|---|---|---|---|---|\n| `GET /api/v1/lookup/{e164}` | inbound (or any) | JSON: validity, line type, carrier, CNAM, `risk{}` | none | `$0.004` fresh CNAM dip / `$0.002` without | account-pooled 429 + `Retry-After`; prepaid 402 before any work | unsigned\ \ |\n| `POST /api/v1/lookup/batch` | bulk (≤100) | same per number + summary | none | same, per number | pooled 429; whole-batch 402 precheck | unsigned |\n| `POST /api/v1/inbound/lookup` | inbound, answer-time | identity type, display label, action hint, full-blend `risk{}`, receipt | mints a single-use `receipt_id` (spendable on `/api/v1/report`) | `$0.004` | pooled 429; prepaid 402; fail-open neutral body on internal errors | **Ed25519-signed** + receipt |\n| `POST /api/v1/outbound/lookup` (alias `/api/v1/precall/lookup`) | outbound, pre-dial | DNC scrub (`SUPPRESS`/`NO_MATCH`/`UNKNOWN`) + `dial_risk` + `cost_estimate` (voice & SMS) | hashed provenance edge when `from` is enrolled | free (bundled) | fair-use pool 429 + `Retry-After`; fail-open `UNKNOWN` | unsigned |\n| `GET /api/v1/cid/{number}` | inbound, PBX | plain-text caller name only | none | same as lookup | EVERY failure is the text sentinel `UNAVAILABLE` (status still meaningful) | plain text, unsigned |\n| `POST /api/v1/sbc/redirect`\ \ | inbound, SIP edge | 302 SIP redirect decision + advisory headers | none | `$0.010` | fail-open `allow` on any error (never blocks a call) | unsigned |\n| `POST /api/v1/mcp` | AI agents | lookup/risk/DNC tools over JSON-RPC | per-tool | `$0.015` per tool call | JSON-RPC errors; transport auth uses the flat error shape | unsigned |\n\nEvery risk read on every row is a supplementary, low-confidence signal — never a\nverdict. On live call paths, prefer the fail-open rows (`cid`, `sbc`, outbound).\n\n## Billing\n\n- **Free tier** — no charge; throttled by low per-key rate limits. Good for trying\n the API and low-volume use.\n- **Standard tier** — prepaid credits, billed per dip against your balance:\n number lookup `$0.004` with a fresh wholesale CNAM dip or `$0.002` when served\n without one (cache hit or no CNAM supplier configured), inbound lookup `$0.004`,\n list scrub `$0.001` per number, the bundled\n AI-voice-agent call — MCP `tools/call` and the Vapi/Retell webhooks — `$0.015`,\n\ \ and the SBC/SIP redirect decision `$0.010`. Invalid numbers and all\n parsing/reference endpoints are free on every tier.\n\n### Billing observability headers\n\nBilled responses carry three headers, sourced from the metering write itself:\n`X-Billed-Micros` (what THIS request cost, in microdollars), `X-Balance-Micros`\n(your remaining prepaid balance after it), and `X-Billing-Endpoint` (the billing\nclass — e.g. `lookup` vs `lookup_enriched`, which is how you observe the\nfresh-dip vs cache price split per call; batch responses list both classes and\nsum the debits). The headers are ABSENT when nothing was debited: free/enterprise\ntiers, invalid numbers, idempotent retries, or when metering failed open.\n\nAdd credit with `POST /api/v1/account/topup` (Stripe Checkout, `$5` minimum).\nWhen a standard-tier balance is exhausted, billed endpoints return **402** — top up\nto resume. Check your balance and recent usage any time with `GET /api/v1/account`.\n\nVerifying a number is separate\ \ from this prepaid API credit. **Personal** number\nverification is a one-time `$9` charge. **Business** number verification is a\n`$29/year` auto-renewing subscription (renews automatically each year; cancel\nanytime) — this is what raises an account's pooled rate limits and report quota.\n\n## Rate Limiting\n\nRate limits are **account-pooled and business-scaled** — they are shared across\nall of an account's keys (not per key), and are recomputed from live state. The\nfree / unverified floor is **10 lookups/min, 50/day, and 10 reports/day**. Each\nverified business number on the account adds **+60/min and +2,000/day lookups and\n+100/day reports**, pooled across every key. Personal ($9) verification does not\nraise these limits. When you exceed a limit the API returns **429** with a\n`Retry-After` header (seconds).\n\n## Error codes\n\nJSON error bodies are flat: `{ \"error\": \"\", \"code\": \"\" }`,\nplus `retry_after_seconds` on 429s and `balance_micros`/`topup_url`/`topup_min_cents`\n\ on insufficient-balance 402s. Switch on `code`, never on the prose. The codes:\n\n| code | HTTP | meaning |\n|---|---|---|\n| `missing_key` | 401 | No API key in the request |\n| `invalid_key` | 401 | Key unknown (or revoked — indistinguishable by design) |\n| `use_case_forbidden` | 403 | Valid key, not enabled for this endpoint's use case |\n| `rate_limited_key` | 429 | Per-key per-minute window exceeded |\n| `rate_limited_pool` | 429 | Account-pooled budget exhausted (lookups, pre-call, daily reports) |\n| `rate_limited_ip` | 429 | Too many failed auth attempts from your IP |\n| `signature_invalid` | 401 | Operator request signing required, missing, or failed |\n| `insufficient_balance` | 402 | Prepaid balance cannot cover the request — top up |\n| `account_suspended` | 403 | Account suspended by the operator; topping up will not help |\n| `paid_verification_required` | 402 | The remedy is a paid number verification, not a top-up |\n| `receipt_invalid` | 409 | Missing, already-used,\ \ or mismatched `receipt_id` |\n\nChannel-specific surfaces keep their own documented contracts instead of this shape:\n`/api/v1/cid` (plain text), the Retell/Vapi webhooks (always 200), the SBC\nredirect's fail-open `allow` bodies, and MCP JSON-RPC message-level errors (MCP\ntransport-level HTTP auth errors do use this shape).\n\n## Schema versioning\n\nEvery JSON shape this platform owns carries a `schema_version` — a date-stamped\nidentifier **unique to that shape** (a given date never identifies two different\nshapes). The bump rule: **additive changes never bump the version; removing,\nrenaming, or retyping a field bumps it.** Generated clients must treat\n`schema_version` as an open string, never a closed enum.\n\nTwo version classes:\n\n- **Negotiable** — unsigned live responses (lookup, batch, precall, scrub,\n report, account…). The version may advance over time; additive fields appear\n without a bump.\n- **Stamped** — Ed25519-signed artifacts (inbound lookup responses, receipts,\n\ \ evidence bundles). The version is fixed into the signed bytes at signing time\n and is never renegotiated — a verifier re-checks exactly what was signed.\n\nProtocol envelopes are exempt: MCP JSON-RPC frames (the embedded tool result is\nversioned instead) and the Vapi/Retell vendor webhook shapes. `/api/v1/cid` is\nplain text and unversioned by design.\n\n### Version changelog\n\n| schema_version | shape |\n|---|---|\n| `2026-05-31` | Inbound lookup response (stamped) |\n| `2026-06-03` | Lookup response — also receipts (pre-policy collision; both frozen) |\n| `2026-06-06` | SBC advisory — also evidence bundles (pre-policy collision; both frozen) |\n| `2026-06-11` | Pre-call lookup (added `dnc_channel`, per-channel DNC scrub) |\n| `2026-06-12` | Batch lookup envelope (introduced) |\n| `2026-06-13` | Report ok-body (introduced) |\n| `2026-06-14` | Pre-call enroll ok-body (introduced) |\n| `2026-06-15` | Account group envelope (introduced; GET and PATCH share the shape) |\n| `2026-06-16`\ \ | Key-lifecycle ok-bodies (introduced; list/mint/update/rotate share the shape) |\n| `2026-06-17` | Signup ok-body (introduced; adds the structured key descriptor) |\n| `2026-06-18` | Parse response (introduced; v1 promotion of `/api/parse` minus the `{success:}` envelope) |\n| `2026-06-19` | Parse batch response (introduced; v1 promotion of `/api/parse/bulk`) |\n| `2026-06-20` | Scrub response (introduced; v1 promotion of `/api/scrub`) |\n| `2026-06-21` | Countries response (introduced; v1 promotion of `/api/countries`) |\n| `2026-06-22` | Account phones resource (introduced; the list and the PATCH share the shape) |\n| `2026-06-23` | Pre-call lookup (cost_estimate restructured: per-channel voice + SMS rates with per-provider breakdown; supersedes the flat per-minute `2026-06-11` shape) |\n\nAdditive change 2026-06-11 (no version bumps, per the bump rule): the\n`risk: { score, level, model }` object was added to the lookup family, the\ninbound lookup (inside the signed body), the\ \ SBC advisory, and — as a `model`\nfield — pre-call's `dial_risk`. See **Risk models** below.\n\n## Risk models\n\nThe platform runs more than one risk model, and historically each surface used\nits own field name without saying which model fed it. Every JSON scoring surface\nnow also carries `risk: { score, level, model }` (additive; the legacy fields\nare frozen forever — copy-paste-distributed parsers hard-match `spam_score`,\nand inbound's `risk_score` is a *different model* under a similar name):\n\n| model | scale | where | sources |\n|---|---|---|---|\n| `first_party_plus_restricted_sources` | 1–99 | `/api/v1/lookup` (+batch), `/api/v1/cid`, SBC advisory, Retell/Vapi variables | First-party reports/reviews/verification + external evidence restricted to open-dataset / regulator-list sources (FTC DNC, FCC complaints, BNetzA enforcement) |\n| `first_party_plus_external` | 0–100 | `/api/v1/inbound/lookup` (signed body) | The full blend, including crowd directories — broader coverage,\ \ mixed provenance |\n| `dial_structural` | 0–100 | `/api/v1/outbound/lookup` `dial_risk` | Outbound structural prior (premium/satellite/IRSF cover) + dynamic fraud observations — the risk of DIALING, not of the caller |\n\nBanding is shared (`<40` low, `<70` medium, `≥70` high; null → `unknown`).\nThe SBC `flag` decision threshold is a separate, operator-overridable policy\nknob (default 80) — `level: \"high\"` does not automatically flag. The numeric\nscales are deliberately not unified; `model` is what tells them apart. Every\nscore remains a labeled, low-confidence supplementary signal — never a verdict.\n\n## Community reporting\n\n`POST /api/v1/report` is the give-to-get half of the community sensor: a key-gated\nendpoint for reporting a number with one or more tags (tags only — no free-text\nbody). Each report spends a single-use `receipt_id` from a prior\n`POST /api/v1/inbound/lookup` on the same number — the receipt is an anti-replay\nnonce and rate control, **not** proof a\ \ call occurred. Accounts with a verified\nbusiness number report in the **accountable** lane (reporter-weighted, earns\ncredibility); free / personal accounts report in the **crowd** lane (a bounded,\ndeferring, credibility-firewalled supplementary signal that cannot sink a verified\nnumber on its own). The daily report quota is pooled per account: 10/day on the\nfree floor, +100/day per verified business number.\n " version: 1.0.0 contact: name: Phone Numbers Online url: https://numbers.online x-apievangelist-provenance: harvested: '2026-09-19' method: searched source: https://numbers.online/api/spec http_status: 200 content_type: application/json note: 'Verbatim harvest of the provider-published OpenAPI 3.0.3 (56 operations, 24 schemas). The JSON original is archived at openapi/_original/numbers-online-openapi.json; this file is the same document serialized as YAML. Ownership: servers[0] is https://numbers.online, info.contact.url is https://numbers.online, and the provider llms.txt/robots.txt/docs all name /api/spec as the source of truth.' servers: - url: https://numbers.online description: Production server - url: http://localhost:3000 description: Development server tags: - name: Account description: 'Self-service API account: signup, balance, usage, credit top-ups, and the key lifecycle. Account-management endpoints require an account-level key with the ''manage'' use case (every self-service key holds it unless deliberately narrowed; the per-key signing config is exempt).' - name: Parsing description: Phone number parsing and validation endpoints - name: Lookup description: 'Number lookup: line type, range carrier, CNAM, verstat, and a supplementary spam signal' - name: PBX description: Plain-text caller-id lookup for header-less PBX integrations (FreeSWITCH mod_cidlookup and similar) - name: Inbound description: Inbound caller-intelligence lookup for operators, PBX, and softphones - name: Outbound description: Outbound pre-call checks (scrub + call-provenance) and number enrollment for spoofing-defense - name: Community reporting description: Key-gated spam/scam reporting — the give-to-get half of the community sensor (receipt-gated, tags-only, lane-scoped supplementary signals) - name: MCP description: 'Model Context Protocol server for AI voice agents (Vapi, Retell, Pipecat, LiveKit): read-only phone-intelligence tools over Streamable HTTP' - name: Webhooks description: AI-voice-agent webhook adapters (Retell inbound, Vapi custom tool) over the same lookup backend - name: SBC / SIP description: SIP redirect-server decisions for session border controllers (Kamailio, OpenSIPS, dSIPRouter, Sansay, Oracle/Acme Packet, ProSBC) via the operator-run shim recipe, plus an FCC robocall-mitigation evidence bundle — a supplementary call-setup signal, fail-open - name: Trust description: Contact-suppression preference lookups - name: MSP description: 'Multi-tenant control plane: per-tenant sub-keys, usage rollups, and suppression lists for MSPs and PBX resellers' - name: Receipts description: Signed, privacy-safe lookup receipts (Ed25519) — verifiable "checked as of T" evidence - name: Reference description: Reference data and examples - name: System description: Operational endpoints (health checks) paths: /api/v1/parse: post: tags: - Parsing summary: Parse a phone number description: Parse and validate a single phone number, returning format variants, country, line type, and validity. Deterministic and free (priced $0; counted for analytics only). Requires an API key with the `parse` use case. The legacy `/api/parse` path is a frozen alias of this endpoint with a `{success:}` envelope. operationId: v1Parse security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: - phoneNumber properties: phoneNumber: type: string description: The phone number to parse (E.164 format recommended) example: '+14155552671' defaultCountry: type: string description: Default country code (ISO 3166-1 alpha-2) for numbers without country code example: US responses: '200': description: Parsed phone number (schema_version-stamped parse row). content: application/json: schema: allOf: - type: object properties: schema_version: type: string example: '2026-06-18' - $ref: '#/components/schemas/ParsedPhoneNumber' '400': description: Invalid request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' /api/v1/parse/batch: post: tags: - Parsing summary: Parse multiple phone numbers description: 'Parse and validate up to 100 numbers in one request (named `batch` to match `/api/v1/lookup/batch`). Non-string entries return a uniform blank row with `valid: false` rather than failing the batch. Deterministic and free (priced $0). Requires the `parse` use case. The legacy `/api/parse/bulk` path is a frozen alias with a `{success:}` envelope.' operationId: v1ParseBatch security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: - phoneNumbers properties: phoneNumbers: type: array items: type: string maxItems: 100 description: Array of phone numbers to parse example: - '+14155552671' - '+442071234567' - '+33123456789' defaultCountry: type: string description: Default country code for numbers without country code example: US responses: '200': description: Parsed phone numbers content: application/json: schema: type: object properties: schema_version: type: string example: '2026-06-19' count: type: integer results: type: array items: $ref: '#/components/schemas/ParsedPhoneNumber' summary: type: object properties: total: type: integer valid: type: integer invalid: type: integer '400': description: Invalid request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' /api/v1/scrub: post: tags: - Trust summary: Scrub a calling list description: 'Call-center list cleaning (API-key gated, `scrub` use case). Submit up to 1,000 numbers and an optional channel; for each, receive SUPPRESS (verified owner has a suppression preference — remove it), NO_MATCH (no suppression preference on record), or UNKNOWN (not resolvable). A compliance aid, not a legal determination; NO_MATCH is not consent. Billing (standard tier): $0.001 per number, debited after delivery, idempotent per content-scoped `Idempotency-Key`. Free tier: not billed — draws from an account-pooled allowance counted in NUMBERS (1,000/min and 5,000/day on the free floor; verified business numbers scale it), so a 1,000-number batch costs 1,000 pool units. The legacy `/api/scrub` path is a frozen alias (same dialect; this path adds `schema_version`).' operationId: v1Scrub security: - ApiKeyAuth: [] - BearerAuth: [] parameters: - name: Idempotency-Key in: header required: false description: 'Optional client-supplied request id for at-most-once billing on retries (content-scoped: the same key with a different number list does not dedupe).' schema: type: string requestBody: required: true content: application/json: schema: type: object required: - numbers properties: numbers: type: array items: type: string maxItems: 1000 example: - '+16285550177' - '+12135559988' channel: type: string enum: - voice - sms - whatsapp default: voice description: Contact channel to check the suppression preference for. responses: '200': description: Per-number suppression status content: application/json: schema: type: object properties: schema_version: type: string example: '2026-06-20' count: type: integer channel: type: string enum: - voice - sms - whatsapp disclaimer: type: string summary: type: object properties: total: type: integer suppress: type: integer description: Numbers with a suppression preference on record no_match: type: integer description: Numbers with no suppression preference (includes opt-ins) unknown: type: integer description: Numbers that could not be resolved results: type: array items: type: object properties: input: type: string e164: type: string status: type: string enum: - SUPPRESS - NO_MATCH - UNKNOWN '400': description: Invalid request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '402': $ref: '#/components/responses/InsufficientBalance' '429': $ref: '#/components/responses/RateLimited' /api/v1/countries: get: tags: - Reference summary: List supported countries description: Reference list of supported countries with calling codes and example numbers. Free (priced $0). Requires the `parse` use case. The legacy `/api/countries` path is a frozen alias with a `{success:}` envelope. operationId: v1Countries security: - ApiKeyAuth: [] - BearerAuth: [] responses: '200': description: List of supported countries content: application/json: schema: type: object properties: schema_version: type: string example: '2026-06-21' count: type: integer countries: type: array items: $ref: '#/components/schemas/Country' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' /api/v1/lookup/{e164}: get: tags: - Lookup summary: Look up a single number description: 'Single-number lookup returning the uniform §2 response shape: deterministic parse fields (validity, formats, line type, range carrier, country) plus cache-aware CNAM, a normalized STIR/SHAKEN verstat, and a supplementary low-confidence spam signal. All enrichment is fail-open — a slow or failing supplier nulls that field rather than erroring. The shape is identical for known and unknown numbers (anti-enumeration). Invalid input returns **200** with `valid: false` and null fields (NOT 404) and is not billed. Requires an API key with the `lookup` use case. Billing (standard tier): `$0.004` when a fresh wholesale CNAM dip is performed, `$0.002` when served without one (CNAM cache hit, or no CNAM supplier configured). The free tier is not billed (rate-limited instead). Billed responses are returned with `Cache-Control: no-store` — the `cached` field and `max_cache_age` param are the cache contract.' operationId: lookupNumber security: - ApiKeyAuth: [] - BearerAuth: [] parameters: - name: e164 in: path required: true description: The number to look up, as a full E.164 string (`+14155552671`), its URL-encoded form (`%2B14155552671`), or a bare digit slug (`14155552671`). schema: type: string example: '+14155552671' - name: verstat in: query required: false description: STIR/SHAKEN verstat passthrough — a bare token (e.g. `TN-Validation-Passed`), a `verstat=...` parameter, or a full SIP/tel header value. Normalized to `verified` / `unverified` / `unknown` in the response. Absence of validation is NOT a failed validation (maps to `unknown`). schema: type: string example: TN-Validation-Passed - name: max_cache_age in: query required: false description: 'Operator TTL control: the maximum acceptable CNAM cache age in seconds. A cached value older than this triggers a fresh wholesale dip; `0` forces a fresh dip (which is billed at the `$0.004` rate).' schema: type: integer minimum: 0 example: 86400 - name: Idempotency-Key in: header required: false description: Optional client-supplied request id for at-most-once billing on retries. Repeated requests with the same key are not double-billed. schema: type: string responses: '200': description: Lookup result (uniform shape for valid, invalid, known, and unknown numbers). headers: Cache-Control: description: Always `no-store` — the response is per-request and billed. schema: type: string example: no-store content: application/json: schema: $ref: '#/components/schemas/LookupResponse' '400': description: Malformed path (not a usable E.164 number) content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '402': $ref: '#/components/responses/InsufficientBalance' '429': $ref: '#/components/responses/RateLimited' /api/v1/lookup/batch: post: tags: - Lookup summary: Look up multiple numbers description: 'Bulk number lookup for list processing. Submit up to 100 numbers; results are returned in input order, each as the same shape as the single lookup. This is a list-processing surface, not a call-path surface — for large batches the supplier dips run with bounded concurrency and can take seconds. Requires an API key with the `lookup` use case. Billing (standard tier) is per VALID number, split by what was delivered: `$0.004` for each number that triggered a fresh wholesale CNAM dip and `$0.002` for each served without one; invalid numbers are free. The free tier is not billed (rate-limited instead). Use the `Idempotency-Key` header for at-most-once billing on retries.' operationId: lookupNumberBatch security: - ApiKeyAuth: [] - BearerAuth: [] parameters: - name: Idempotency-Key in: header required: false description: Optional client-supplied request id for at-most-once billing on retries. schema: type: string requestBody: required: true content: application/json: schema: type: object required: - numbers properties: numbers: type: array items: type: string minItems: 1 maxItems: 100 description: Numbers to look up (E.164 recommended). Maximum 100 per request. example: - '+14155552671' - '+442071234567' verstat: type: string description: STIR/SHAKEN verstat passthrough applied to every number in the batch. Same accepted forms as the single-lookup query param. example: TN-Validation-Passed max_cache_age: type: integer minimum: 0 description: 'Operator TTL control: maximum acceptable CNAM cache age in seconds; `0` forces a fresh dip.' example: 86400 responses: '200': description: Per-number lookup results plus a batch billing summary. content: application/json: schema: $ref: '#/components/schemas/LookupBatchResponse' '400': description: Invalid request (missing/empty `numbers`, over 100, or malformed JSON) content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '402': $ref: '#/components/responses/InsufficientBalance' '429': $ref: '#/components/responses/RateLimited' /api/v1/lookup/changes: get: tags: - Lookup summary: Reputation-change feed description: 'Poll the numbers whose scraped-intel rollup was updated since a cursor, so cached lookups can be refreshed when the underlying intel moves instead of on a blind timer. Returns up to `limit` entries ordered by update time (ascending) plus a `cursor` — pass it as the next request''s `since`; with no `since`, returns the last hour. Two caveats to build against: (1) an entry means the number''s intel row was re-written by ingestion, which includes re-observations that left every value unchanged — treat it as a refresh hint, not proof of movement; (2) ingest batches stamp many rows with one identical update timestamp and the cursor is a strict greater-than, so a page boundary landing inside such a batch skips its remaining same-timestamp rows — use `limit=1000` (the maximum) so pages rarely split a batch. The values are the intel layer''s own signal, a re-dip HINT: the authoritative blended score is still `GET /api/v1/lookup/{e164}`, so the intended loop is poll changes → re-dip the numbers you care about, while still honoring the Terms §7 caching bounds (refresh or drop cached responses within 30 days even when no entry arrives). Not separately metered — it rides the account''s normal API access and per-key rate limits. Requires the `lookup` use case.' operationId: lookupChanges security: - ApiKeyAuth: [] - BearerAuth: [] parameters: - name: since in: query required: false description: ISO-8601 cursor — use the previous response's `cursor`. Defaults to one hour ago. schema: type: string format: date-time example: '2026-08-01T00:00:00.000Z' - name: limit in: query required: false description: Maximum changes per page (default 100, max 1000). schema: type: integer minimum: 1 maximum: 1000 default: 100 responses: '200': description: Numbers whose intel reputation changed since the cursor, oldest change first. headers: Cache-Control: description: Always `no-store` — the feed is a live cursor read. schema: type: string example: no-store content: application/json: schema: type: object properties: since: type: string format: date-time description: The cursor this page was read from. cursor: type: string format: date-time description: The max change time in this page — pass as the next request's `since`. count: type: integer has_more: type: boolean description: True when the page filled `limit`; poll again immediately with `cursor`. Prefer `limit=1000` so a page boundary rarely lands inside one ingest batch (see the endpoint description). changes: type: array items: type: object properties: e164: type: string example: '+14155551212' risk_score: type: integer nullable: true description: Intel-layer weighted risk 0–100 (higher = worse) — a supplementary re-dip hint, NOT the blended lookup score. risk_level: type: string enum: - low - medium - high - unknown top_category: type: string nullable: true total_reports: type: integer has_verified_regulator: type: boolean last_observed_at: type: string format: date-time nullable: true changed_at: type: string format: date-time '400': description: Malformed `since` (must be ISO-8601 — use the previous response's `cursor`) content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' '503': description: Change feed temporarily unavailable content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/inbound/lookup: post: tags: - Inbound summary: Inbound caller lookup description: 'Signed, privacy-safe caller intelligence as a supplementary signal for an inbound call/message event. Returns identity type, a risk score (0-100, higher = worse), evidence signals, and a recommended action. A verified individual returns only "Verified & online" — never a name. Requires an API key with the inbound_lookup use case. Billed per dip on the standard tier ($0.004); free on the free tier (rate-limited, and a high-risk caller is degraded from block_candidate to challenge_or_route). Anti-enumeration: a known and an unknown number return the same 200 shape (found vs no_record). Caller-ID-authentication inputs `verstat` and `attestation` are read at the top level: a supplied value modulates whether this number''s standing applies to THIS call (a verified call trusts our read; a failed validation may indicate spoofing of the number) and, when an auth signal is present, contributes to a corroboration-gated spoofing-prevalence signal accumulated across sources — a supplementary signal, never stamped onto the number''s stored standing; absence is not a failed validation. The optional `to` (the receiver''s own number) binds call-provenance. The remaining fields (destination_number, client_type, client_name, the nested stir_shaken object, call_id_hash, timestamp) are accepted but currently reserved and ignored.' operationId: inboundLookup security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: - number properties: number: type: string example: '+14155551212' context: type: string enum: - inbound_voice - inbound_sms - inbound_waba verstat: type: string description: STIR/SHAKEN verstat passthrough — a bare token (e.g. TN-Validation-Passed), a verstat=… parameter, or a full SIP/tel header value; normalized to verified/unverified/unknown. Absence is not a failed validation. example: TN-Validation-Passed attestation: type: string enum: - A - B - C description: P-Attestation-Indicator level; an explicit A/B/C overrides verstat. to: type: string description: The receiver's own number (E.164). Binds call-provenance so a later spoofing report can be attributed; stored only as a hash. example: '+14155550100' destination_number: type: string description: Reserved; accepted but currently ignored. client_type: type: string description: Reserved; accepted but currently ignored. client_name: type: string description: Reserved; accepted but currently ignored. call_id_hash: type: string description: Reserved; accepted but currently ignored. timestamp: type: string format: date-time description: Reserved; accepted but currently ignored. responses: '200': description: Signed caller assessment content: application/json: schema: $ref: '#/components/schemas/InboundLookupResponse' '400': description: Invalid request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '402': $ref: '#/components/responses/InsufficientBalance' '429': $ref: '#/components/responses/RateLimited' /api/v1/outbound/lookup: post: tags: - Outbound summary: Outbound pre-call check (scrub + provenance) description: 'An outbound integration''s pre-call check on a destination. Returns the M1 pre-call scrub on `to` (SUPPRESS | NO_MATCH | UNKNOWN) plus three more supplementary signals: a compliance signal; an outbound `dial_risk` read — a low-confidence estimate of the cost/abuse risk of DIALING `to` (premium / satellite / international / IRSF-cover structural prior, plus any dynamic fraud observations); and a `cost_estimate` — the indicative RETAIL cost to reach `to`, priced for BOTH channels (voice per minute, SMS per message) from aggregated provider list-price decks, with a per-provider breakdown. All are supplementary signals, NEVER a consent grant or a block verdict: NO_MATCH is not permission to call and a dial_risk level is not a block — you remain responsible for your own lawful basis and dialing decision. For an ENROLLED caller (a verified business number you enrolled via /api/v1/outbound/enroll), it also records a hashed, short-TTL (from -> to) call-provenance edge: a self-incriminating record of who you actually dialled, used at report time to hold your number accountable for calls it DID place and to shield it from reports about calls it did NOT place (spoofing). Both ends are hashed, server-only, and never surfaced. Non-enrolled callers still get the scrub; no edge is written. Requires an API key with the `precall` use case. Free (bundled); not metered. Supplementary signal only — no per-call verdict. This is the canonical spelling — the outbound mirror of /api/v1/inbound/lookup; the original /api/v1/precall/lookup stays a permanent alias served by the same handler.' operationId: precallLookup security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: - to properties: to: type: string description: The destination number being called. example: '+14155551212' from: type: string description: Your own calling number. A provenance edge is recorded only when this is a number you have enrolled. example: '+442071838750' context: type: string enum: - outbound_voice - outbound_sms description: 'The channel you are about to use. Drives which suppression list is scrubbed: outbound_sms scrubs SMS preferences, anything else scrubs voice. Echoed back as `dnc_channel`.' responses: '200': description: Pre-call scrub (+ provenance edge when enrolled) content: application/json: schema: type: object properties: schema_version: type: string example: '2026-06-23' to: type: string dnc: type: string enum: - SUPPRESS - NO_MATCH - UNKNOWN dnc_channel: type: string enum: - voice - sms description: Which suppression list the `dnc` answer was scrubbed against (derived from `context`). dnc_note: type: string compliance: type: object nullable: true properties: dnc_status: type: string reassigned_status: type: string dial_risk: type: object nullable: true description: 'Outbound dial-risk signal: the cost/abuse risk of DIALING `to` (structural prior plus any dynamic fraud observations). Supplementary signal, not a block verdict. Null if scoring failed (fail-open).' properties: risk: type: integer description: Risk score 0–100. example: 6 level: type: string enum: - low - medium - high description: 'Banded risk: low (<40), medium (40–69), high (≥70).' model: type: string enum: - dial_structural description: 'Scoring model (§ Risk models): the outbound structural model — NOT the inbound reputation blend.' structural_type: type: string nullable: true enum: - premium_prs - satellite - intl_network - intl_premium - freephone - unallocated_dialable - ordinary description: The structural class the destination resolved to (null if no range matched). reason_codes: type: array items: type: string description: Legible drivers, e.g. `structural:intl_premium` or `dynamic:`. example: - structural:intl_premium note: type: string cost_estimate: type: object nullable: true description: Indicative RETAIL cost to reach `to`, from aggregated provider price-list decks (longest-prefix match, filtered by the destination's parsed line type). Returns BOTH channels regardless of `context` — `voice` priced per minute, `sms` per message — each null when no deck covers that channel; the whole object is null when neither does (fail-open). All amounts are decimal USD strings. Supplementary signal, never wholesale interconnect cost and never a quote. properties: currency: type: string enum: - USD note: type: string voice: allOf: - $ref: '#/components/schemas/ChannelCost' nullable: true description: Per-minute voice rate, or null if no voice deck covers the destination. sms: allOf: - $ref: '#/components/schemas/ChannelCost' nullable: true description: Per-message SMS rate, or null if no SMS deck covers the destination. enrolled: type: boolean provenance_recorded: type: boolean provenance_note: type: string edge_id: type: string ttl_seconds: type: integer example: 604800 '400': description: Invalid request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' /api/v1/outbound/enroll: post: tags: - Outbound summary: Enroll a number for call-provenance description: 'Owner-only control that enrolls (or, with `enrolled: false`, revokes) a verified business number you own for call-provenance. Enrollment is your attestation that this number only places calls after a Numbers Online outbound pre-call lookup, so the absence of a matching pre-call edge becomes a supplementary signal that a complaint may concern a spoofed call rather than one you placed. Only numbers bound to your own account can be enrolled; revocable and abuse-monitored. Requires an account-level API key with the `precall` use case (not a tenant sub-key). Supplementary signal only — not a compliance determination. This is the canonical spelling; the original /api/v1/precall/enroll stays a permanent alias served by the same handler. The same toggle is also available resource-shaped: GET /api/v1/account/phones lists your numbers, PATCH /api/v1/account/phones/{e164} flips enrollment.' operationId: precallEnroll security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: - number properties: number: type: string example: '+442071838750' enrolled: type: boolean default: true description: Set false to revoke enrollment. responses: '200': description: Enrollment updated content: application/json: schema: type: object properties: schema_version: type: string example: '2026-06-14' ok: type: boolean number: type: string enrolled: type: boolean attestation: type: string '400': description: Invalid request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Tenant sub-keys cannot manage enrollment content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Number not bound to your account content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/precall/lookup: post: tags: - Outbound summary: Outbound pre-call check — original spelling (permanent alias) description: Permanent alias of POST /api/v1/outbound/lookup — the same handler under the original path, kept forever (it is baked into published guides and agent prompts already in the field). Request, response, auth, billing, and limits are identical; see the canonical entry. Prefer /api/v1/outbound/lookup in new integrations. operationId: precallLookupAlias deprecated: true security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: - to properties: to: type: string example: '+14155551212' from: type: string example: '+442071838750' context: type: string enum: - outbound_voice - outbound_sms responses: '200': description: Identical to POST /api/v1/outbound/lookup (same handler). '400': description: Invalid request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' /api/v1/precall/enroll: post: tags: - Outbound summary: Enroll a number — original spelling (permanent alias) description: Permanent alias of POST /api/v1/outbound/enroll — the same handler under the original path, kept forever. Request, response, auth, and limits are identical; see the canonical entry. Prefer /api/v1/outbound/enroll (or the resource form PATCH /api/v1/account/phones/{e164}) in new integrations. operationId: precallEnrollAlias deprecated: true security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: - number properties: number: type: string example: '+442071838750' enrolled: type: boolean default: true responses: '200': description: Identical to POST /api/v1/outbound/enroll (same handler). '400': description: Invalid request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Tenant sub-keys cannot manage enrollment content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Number not bound to your account content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/cid/{number}: get: tags: - PBX summary: Plain-text caller-id lookup description: 'Caller-name (CNAM) lookup that ALWAYS returns `text/plain` — every response, including auth, balance, and rate-limit failures, is plain text so a PBX can paste the body verbatim into a caller name without ever rendering a JSON error blob. Built for header-less integrations such as FreeSWITCH `mod_cidlookup`, which substitutes the raw inbound number into a URL and uses the response body as the caller name. The body is the resolved name (`ACME CORP`), the name prefixed with an operator-chosen risk tag when one is configured and the spam signal crosses the threshold (`Spam? ACME CORP`), or the literal sentinel `UNAVAILABLE` when there is no name, the number is unresolvable, the caller is tenant-suppressed, or auth/balance/rate-limit fails (the HTTP status code is still meaningful — `200` for an ordinary no-result, `401`/`402`/`429` for the failure cases). The number is resolved loosely: 10/11-digit national, `+`E.164, or `00`-prefixed international are all accepted (anonymous callers and alphanumeric SIP user-parts yield `UNAVAILABLE`). Billing is identical to /api/v1/lookup (`$0.004` on a fresh wholesale CNAM dip, `$0.002` otherwise; unresolvable and tenant-suppressed numbers are free). Requires an API key with the `lookup` use case. Fail-open: a slow or failing supplier yields `UNAVAILABLE`, never an error on the call path.' operationId: cidLookup security: - ApiKeyAuth: [] - BearerAuth: [] - CidQueryKeyAuth: [] parameters: - name: number in: path required: true description: The inbound caller number as it arrived from the trunk — a bare national number (`2025550123`), 11-digit (`12025550123`), full E.164 (`+12025550123`), or its URL-encoded form. PBX clients substitute this token (e.g. mod_cidlookup's `${caller_id_number}`) with no normalization. schema: type: string example: '12025550123' - name: key in: query required: false description: 'API key as a query param. Intended for header-less integrations (e.g. FreeSWITCH `mod_cidlookup`) that cannot send an `Authorization`/`X-API-Key` header. Also accepted by the webhook adapters (`/api/v1/integrations/retell/inbound`, `/api/v1/integrations/vapi/tool`, `/api/v1/sbc/redirect`) for the same reason. The key can land in proxy/gateway access logs, so use a dedicated, rotated key. Prefer header auth (`Authorization: Bearer` / `X-API-Key`) wherever the client can send headers.' schema: type: string example: nol_YOUR_API_KEY - name: country in: query required: false description: Default country (ISO 3166-1 alpha-2) used to resolve bare national numbers that arrive without a country code. Defaults to `US`. schema: type: string example: US - name: spam_tag in: query required: false description: Operator-opt-in risk prefix. When set, a caller whose supplementary spam signal is at or above `spam_threshold` has the body returned prefixed with this text (e.g. `Spam? ACME CORP`, or the tag alone when no name is available). When absent, the risk signal NEVER alters the body — risk wording is strictly the operator's choice. schema: type: string example: Spam? - name: spam_threshold in: query required: false description: Spam-signal threshold (1–99, default 80) at or above which the `spam_tag` prefix is applied. Has no effect unless `spam_tag` is also set. schema: type: integer minimum: 1 maximum: 99 default: 80 example: 80 - name: max_cache_age in: query required: false description: 'Operator TTL control: maximum acceptable CNAM cache age in seconds. A cached value older than this triggers a fresh wholesale dip; `0` forces a fresh dip (billed at the `$0.004` rate).' schema: type: integer minimum: 0 example: 300 responses: '200': description: Plain-text caller name, an operator-tagged name, or the `UNAVAILABLE` sentinel (no name, unresolvable number, or tenant-suppressed caller). Always `text/plain`. headers: Cache-Control: description: Always `no-store` — the response is per-request and billed. schema: type: string example: no-store content: text/plain: schema: type: string example: ACME CORP '401': description: 'Authentication failed (missing or invalid key). Body is still plain text: `UNAVAILABLE`.' content: text/plain: schema: type: string example: UNAVAILABLE '402': description: 'Standard-tier prepaid balance is exhausted. Body is still plain text: `UNAVAILABLE`. Top up (POST /api/v1/account/topup) to resume.' content: text/plain: schema: type: string example: UNAVAILABLE '429': description: Per-key rate limit exceeded. Body is plain text `UNAVAILABLE`; a `Retry-After` header (seconds) is set. headers: Retry-After: description: Seconds to wait before retrying. schema: type: integer content: text/plain: schema: type: string example: UNAVAILABLE /api/v1/cid: get: tags: - PBX summary: Plain-text caller-id (zero-segment fallback) description: Zero-segment fallback for a PBX whose URL template failed to substitute the caller number (so the request arrives at `/api/v1/cid` with no number). Always returns `text/plain` `UNAVAILABLE` with HTTP 200, so a broken template never renders a 404 page as the caller name. Takes no key and runs no lookup — purely the safe default for `mod_cidlookup` and similar header-less clients still being wired up. operationId: cidLookupFallback security: [] responses: '200': description: Always the plain-text sentinel `UNAVAILABLE`. headers: Cache-Control: description: Always `no-store`. schema: type: string example: no-store content: text/plain: schema: type: string example: UNAVAILABLE /api/v1/account/signup: post: tags: - Account summary: Sign up for an API key description: Self-service account creation. NO AUTH REQUIRED — the API key plus the returned account UUID is your identity, not an email. The raw key (format `nol_…`) is returned exactly once and is never recoverable, and there is no recovery path, so store it securely. Each call mints a fresh account. Email is OPTIONAL (used only to send subscription renewal reminders) — never required, never deduplicated. New accounts start on the free tier with a zero credit balance; add credit via /api/v1/account/topup to use billed (standard-tier) endpoints. operationId: accountSignup requestBody: required: false content: application/json: schema: type: object properties: name: type: string description: Optional account/team label (never a personal name). example: Acme Telephony email: type: string format: email description: Optional contact email, used only for subscription renewal reminders. Not required and not unique. example: ops@acme.example responses: '201': description: Account created; key returned once. content: application/json: schema: type: object properties: schema_version: type: string example: '2026-06-17' account: type: object properties: id: type: string format: uuid email: type: string format: email nullable: true balance_micros: type: integer example: 0 api_key: type: string description: The raw key — shown ONLY here, never again. example: nol_8f3c2a1b9d4e6f7a8b9c0d1e2f3a4b5c6d7e8f9a key_prefix: type: string description: Non-secret display prefix you can store/show. example: nol_8f3c2a1b key: type: object description: Machine-readable key descriptor (§4.6). The pooled limits are the unverified FLOOR — budgets are recomputed per-request from live account state, and each verified business number raises them (+60/min, +2,000/day lookups, +100/day reports). The flagship lookup endpoints are governed by the POOLED budget shared across your keys, not the per-key window. properties: tier: type: string enum: - free - standard - enterprise example: free use_cases: type: array items: type: string limits: type: object properties: per_key_per_min: type: integer example: 60 pooled_lookups_per_min: type: integer example: 10 pooled_lookups_per_day: type: integer example: 50 pooled_reports_per_day: type: integer example: 10 limits_note: type: string note: type: string description: Key-handling and tier guidance. docs_url: type: string example: https://numbers.online/docs '400': description: A supplied email is malformed (email is optional; omit it to skip). content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: Signup velocity limit (3/hour per IP). Retry-After header is set. content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/account: get: tags: - Account summary: Get account balance and usage description: 'Returns the authenticated key''s account: prepaid balance and recent usage (request count and billed amount over the trailing window). Use this to monitor spend and decide when to top up.' operationId: getAccount security: - ApiKeyAuth: [] - BearerAuth: [] responses: '200': description: Account balance, trailing-30-day usage, and key list content: application/json: schema: type: object properties: schema_version: type: string example: '2026-06-15' description: Account-group envelope version (shared by GET and the PATCH ok-body). account: $ref: '#/components/schemas/Account' usage_30d: $ref: '#/components/schemas/UsageSummary' keys: type: array description: Keys belonging to this account (display fields only — never the raw key or its hash). items: type: object properties: id: type: string format: uuid key_prefix: type: string example: nol_8f3c2a1b name: type: string tier: type: string enum: - free - standard - enterprise rate_limit: type: integer description: Requests per 60-second window. requests_total: type: integer last_used_at: type: string format: date-time nullable: true disabled: type: boolean tenants: type: array description: 'MSP control plane (Phase 2.5): this account''s tenants. Detail and per-tenant usage live at GET /api/v1/account/tenants.' items: type: object properties: id: type: string format: uuid name: type: string example: Dental office status: type: string enum: - active - disabled '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Tenant sub-keys cannot read the owning account (use an account-level key) content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: The calling key has no associated account (legacy/internal key) content: application/json: schema: $ref: '#/components/schemas/Error' patch: tags: - Account summary: Update account name / reminder email description: Update account-level profile fields (name and/or the optional renewal-reminder email) from your own admin. Account-level keys only. Email is optional and not unique; send an empty string to clear it. operationId: updateAccount security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: true content: application/json: schema: type: object properties: name: type: string example: Acme Voice email: type: string format: email description: Optional renewal-reminder email; empty string clears it. example: ops@acme.example responses: '200': description: Updated account content: application/json: schema: type: object properties: schema_version: type: string example: '2026-06-15' ok: type: boolean account: $ref: '#/components/schemas/Account' '400': description: Nothing to update or invalid email content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/account/listings/{e164}: parameters: - name: e164 in: path required: true schema: type: string description: An E.164 number OTP-bound to this account (with or without the leading +). get: tags: - Account summary: Onboarding state + live trust for an account-owned number description: 'API-first onboarding: read the onboarding state of one of your bound numbers — its draft profile, verification badge (trust_grade, set at verification), and the LIVE community risk score (risk.score, which can rise as the number is reported). Poll this after a verification payment to see state flip to "verified".' operationId: getAccountListing security: - ApiKeyAuth: [] - BearerAuth: [] responses: '200': description: State, trust badge, live risk, listing, and any staged draft content: application/json: schema: type: object properties: e164: type: string state: type: string enum: - unverified - draft - verified trust_grade: type: string nullable: true description: Static verification badge (A / A-), set at verification. risk: type: object nullable: true properties: score: type: integer description: Live community risk 0–100 (higher = more risk). A supplementary signal. report_count: type: integer reports_last_30d: type: integer last_reported_at: type: string format: date-time nullable: true review_total: type: integer nullable: true review_positive: type: integer nullable: true listing: type: object nullable: true description: Present once verified. draft: type: object nullable: true description: Staged-but-unpaid profile. '404': description: Number is not OTP-bound to this account content: application/json: schema: $ref: '#/components/schemas/Error' put: tags: - Account summary: Stage a draft profile (or edit a live listing) description: Stage/merge a business or personal profile for a bound number before verification (partial — only the keys you send are updated). Once the number is verified, the same call edits the live listing. Tax id / EIN (business) is stored privately and never published. Use POST .../logo to attach a logo. operationId: putAccountListing security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: true content: application/json: schema: type: object properties: kind: type: string enum: - business - personal description: Sets the verification kind for a new draft (default business). dba: type: string description: Display name (business). legal_name: type: string ein: type: string description: Tax id — stored privately, never published. jurisdiction: type: string formation_date: type: string example: '2019-04-01' industry: type: string address: type: string website: type: string bio: type: string name: type: string description: Display name (personal). role: type: string marketing_opt_in: type: boolean responses: '200': description: Draft staged or live listing edited content: application/json: schema: type: object properties: ok: type: boolean state: type: string enum: - draft - verified verification_id: type: string format: uuid '400': description: No editable fields provided content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Listing was claimed on the website (edit it there) content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Number is not OTP-bound to this account content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/account/listings/{e164}/verify-checkout: parameters: - name: e164 in: path required: true schema: type: string post: tags: - Account summary: Mint a verification payment link description: Create a Stripe Checkout for personal ($9 one-time) or business ($29/yr) verification of a bound number — the kind comes from the staged draft. Open the returned url in a browser tab; once paid, the listing is PUBLISHED server-side (no return trip needed) and GET .../listings/{e164} flips to "verified". A profile must be staged first via PUT. operationId: verifyCheckout security: - ApiKeyAuth: [] - BearerAuth: [] responses: '200': description: Checkout URL (Stripe) or instant verified (dev provider) content: application/json: schema: type: object properties: ok: type: boolean url: type: string nullable: true state: type: string verification_id: type: string format: uuid handle: type: string nullable: true '400': description: No draft profile staged yet content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: Number already has a verified listing content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/account/listings/{e164}/logo: parameters: - name: e164 in: path required: true schema: type: string post: tags: - Account summary: Upload a business logo description: Upload a logo for a bound business number. Send the RAW image bytes as the request body with a Content-Type of image/png, image/jpeg, or image/webp (max 512 KB; SVG not accepted). The returned logo_url is stamped onto the draft (so it publishes with the listing) and onto a live listing if one exists. operationId: uploadListingLogo security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: true content: image/png: schema: type: string format: binary image/jpeg: schema: type: string format: binary image/webp: schema: type: string format: binary responses: '200': description: Stored; returns the public logo_url content: application/json: schema: type: object properties: ok: type: boolean media_id: type: string format: uuid logo_url: type: string byte_size: type: integer content_type: type: string '413': description: Image too large (max 512 KB) content: application/json: schema: $ref: '#/components/schemas/Error' '415': description: Unsupported image type content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/account/listings/{e164}/documents: parameters: - name: e164 in: path required: true schema: type: string post: tags: - Account summary: Upload a verification document description: Upload a supporting document an operator requested during verification of a bound number (the request-docs flow). Send the RAW file bytes as the request body with a Content-Type of application/pdf, image/png, image/jpeg, or image/webp (max 5 MB; SVG not accepted). When the number has an open draft verification, the stored document is attached to it so the operator's review can see what was received; with no open draft the document is stored but attached to nothing — upload while your verification is still in the draft/review state. operationId: uploadListingDocument security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: true content: application/pdf: schema: type: string format: binary image/png: schema: type: string format: binary image/jpeg: schema: type: string format: binary image/webp: schema: type: string format: binary responses: '200': description: Stored; returns the document reference content: application/json: schema: type: object properties: ok: type: boolean media_id: type: string format: uuid document_url: type: string byte_size: type: integer content_type: type: string '400': description: Empty body — send the raw document bytes content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Number is not OTP-bound to this account content: application/json: schema: $ref: '#/components/schemas/Error' '413': description: Document too large (max 5 MB) content: application/json: schema: $ref: '#/components/schemas/Error' '415': description: Unsupported document type content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/account/listings/{e164}/reports: parameters: - name: e164 in: path required: true schema: type: string - name: limit in: query required: false schema: type: integer default: 50 maximum: 200 get: tags: - Account summary: Reports + reviews filed against an owned number description: 'List the community reports and reviews on one of your bound numbers — the detail behind the risk score. Anonymous reporters are redacted (reporter: null).' operationId: getAccountListingReports security: - ApiKeyAuth: [] - BearerAuth: [] responses: '200': description: Reports + reviews (newest first) content: application/json: schema: type: object properties: e164: type: string summary: type: object reports: type: array items: type: object reviews: type: array items: type: object '404': description: Number is not OTP-bound to this account content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/account/topup: post: tags: - Account summary: Add prepaid credit description: 'Create a Stripe Checkout session to add prepaid credit to the authenticated account. Credit is applied to the balance when Stripe confirms payment (via /api/pay/webhook), idempotently keyed on the session id. Amount is in US cents: minimum $5 (500), maximum $500 (50000) per checkout. Requires Stripe to be configured server-side.' operationId: accountTopup security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: - amount_cents properties: amount_cents: type: integer minimum: 500 maximum: 50000 description: Credit to add, in US cents ($5–$500). example: 2000 promote_keys: type: boolean default: true description: By default a completed top-up switches any free-tier keys on the account to the metered standard tier. Pass false to opt out — but note an opted-out key stays priced $0 and is never debited, so the paid balance is unspendable until a later top-up promotes the keys. responses: '200': description: Checkout session created content: application/json: schema: type: object properties: url: type: string description: Stripe Checkout URL to redirect the buyer to. session_id: type: string description: Stripe Checkout session id. promote_keys: type: boolean description: Echo of the promotion behavior this checkout will apply. note: type: string '400': description: Invalid amount (below $5 or above $500) content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: The calling key has no associated account content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: Stripe is not configured server-side content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/account/keys: get: tags: - Account summary: List API keys description: Display-only inventory of every key on the account (account-level keys and tenant sub-keys) — prefixes, tiers, use cases, limits, and rotation-grace state. Raw keys and hashes are never returned. Requires an account-level key with the 'manage' use case. operationId: listAccountKeys security: - ApiKeyAuth: [] - BearerAuth: [] responses: '200': description: Key inventory content: application/json: schema: type: object properties: schema_version: type: string example: '2026-06-16' keys: type: array items: $ref: '#/components/schemas/ApiKeyView' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Key lacks the 'manage' use case, or is a tenant sub-key content: application/json: schema: $ref: '#/components/schemas/Error' post: tags: - Account summary: Mint a new API key description: Create a new key on the account. The key inherits the caller's tier (tier is never body-controlled), and `use_cases` must be a subset of the caller's own — a minted key can never out-privilege its minter; omit it to clone the caller's list. Capped at 25 enabled keys per account plus a minting rate limit. The raw key is returned exactly once. Requires the 'manage' use case. operationId: createAccountKey security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: false content: application/json: schema: type: object properties: name: type: string example: Dialer integration use_cases: type: array items: type: string description: Subset of your own key's use cases. Omit to clone the caller's list. example: - lookup - scrub responses: '201': description: Key created — the raw key is shown only here content: application/json: schema: type: object properties: schema_version: type: string example: '2026-06-16' api_key: type: string example: nol_… key: $ref: '#/components/schemas/ApiKeyView' note: type: string '400': description: use_cases not a subset of the caller's own content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Key lacks the 'manage' use case, or is a tenant sub-key content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: Enabled-key ceiling reached (25/account) content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' /api/v1/account/keys/{id}: parameters: - name: id in: path required: true schema: type: string format: uuid get: tags: - Account summary: Read one API key description: Display-only view of one key. A foreign or unknown id returns the same 404 as a malformed one (anti-enumeration). operationId: getAccountKey security: - ApiKeyAuth: [] - BearerAuth: [] responses: '200': description: Key content: application/json: schema: type: object properties: schema_version: type: string example: '2026-06-16' key: $ref: '#/components/schemas/ApiKeyView' '404': description: Key not found content: application/json: schema: $ref: '#/components/schemas/Error' patch: tags: - Account summary: Rename or disable an API key description: 'Update a key''s `name`, or revoke it with `disabled: true`. Disabling is ONE-WAY (a disabled key reads as invalid — rotate or mint instead of re-enabling), and disabling the LAST enabled account-level key is refused with 409: the key + account UUID is the identity (no recovery flow), so that would permanently brick the account and strand its balance.' operationId: updateAccountKey security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: true content: application/json: schema: type: object properties: name: type: string disabled: type: boolean description: Only `true` is accepted (one-way revocation). responses: '200': description: Updated content: application/json: schema: type: object properties: schema_version: type: string example: '2026-06-16' ok: type: boolean key: $ref: '#/components/schemas/ApiKeyView' '400': description: Nothing to update, or disabled:false (re-enabling unsupported) content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Key not found content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: 'Refused: last enabled account-level key' content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/account/keys/{id}/rotate: post: tags: - Account summary: Rotate an API key in place description: 'Replace the key material on the SAME key record — the id, billing identity, rate buckets, and the HKDF-derived operator signing secret are unchanged, so signed-request SBC integrations keep verifying. The previous key keeps working for a grace window (default and maximum 24 h; pass `grace_seconds: 0` to kill it immediately, e.g. after a leak). The new raw key is returned exactly once.' operationId: rotateAccountKey security: - ApiKeyAuth: [] - BearerAuth: [] parameters: - name: id in: path required: true schema: type: string format: uuid requestBody: required: false content: application/json: schema: type: object properties: grace_seconds: type: integer minimum: 0 maximum: 86400 default: 86400 description: How long the previous key keeps validating. responses: '200': description: Rotated — the new raw key is shown only here content: application/json: schema: type: object properties: schema_version: type: string example: '2026-06-16' ok: type: boolean api_key: type: string example: nol_… key: $ref: '#/components/schemas/ApiKeyView' old_key_expires_at: type: string format: date-time nullable: true description: When the previous key dies; null when grace_seconds was 0. note: type: string '404': description: Key not found content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: Cannot rotate a disabled key content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/account/phones: get: tags: - Account summary: List your verified numbers (enrollment inventory) description: 'The verified business numbers bound to the calling account — the inventory the verify-phone OTP bind writes into — each with its call-provenance enrollment state. Account-level API keys only (tenant sub-keys 403). There is deliberately no POST and no DELETE here: numbers enter exclusively via the OTP bind (POST /api/v1/account/verify-phone/start — the Sybil floor) and leave via the reassignment lifecycle.' operationId: accountPhonesList security: - ApiKeyAuth: [] - BearerAuth: [] responses: '200': description: Your bound numbers content: application/json: schema: type: object properties: schema_version: type: string example: '2026-06-22' phones: type: array items: $ref: '#/components/schemas/AccountPhone' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Tenant sub-keys cannot read the account inventory content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/account/phones/{e164}: patch: tags: - Account summary: Enroll / revoke call-provenance (resource form) description: 'Resource-shaped equivalent of POST /api/v1/outbound/enroll: set `precall_enrolled` to true to enroll the number for call-provenance, false to revoke. STRICT update — the number must already be a verified number bound to your own account (404 otherwise); rows are never created or deleted here (creation is exclusively the OTP bind). Requires an account-level API key with the `precall` use case (not a tenant sub-key). Supplementary signal only — not a compliance determination.' operationId: accountPhonePatch security: - ApiKeyAuth: [] - BearerAuth: [] parameters: - name: e164 in: path required: true description: The bound number — '+14155552671', its URL-encoded form, or a bare digit slug '14155552671'. schema: type: string example: '+442071838750' requestBody: required: true content: application/json: schema: type: object required: - precall_enrolled properties: precall_enrolled: type: boolean description: true enrolls the number for call-provenance; false revokes. responses: '200': description: Enrollment updated content: application/json: schema: type: object properties: schema_version: type: string example: '2026-06-22' phone: $ref: '#/components/schemas/AccountPhone' attestation: type: string description: The enrollment attestation; present only when enrolling. '400': description: Invalid request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Tenant sub-keys cannot manage enrollment content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Number not bound to your account content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/account/verify-phone/start: post: tags: - Account summary: 'Verify a business number (step 1: send code)' description: Step 1 of binding a business number to your account. Sends a one-time code by voice (default) or sms to a number your account controls, so an integration can prove control server-to-server. Account-level keys only (a tenant sub-key returns 403; an ownerless key 404). Binding an OTP-verified business number is what raises pooled limits (+60/min, +2,000/day lookups, +100/day reports), unlocks the accountable report lane, and enables pre-call enrollment. operationId: accountVerifyPhoneStart security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: - phone properties: phone: type: string description: The business number to verify, E.164. example: '+14155550142' channel: type: string enum: - voice - sms default: voice description: Delivery channel for the one-time code (voice is the cheaper default). responses: '200': description: Code sent content: application/json: schema: type: object properties: ok: type: boolean dev_code: type: string description: The code, echoed only in the dev OTP provider; never present in production. '400': description: Invalid phone, or the OTP request was rejected content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: A tenant sub-key cannot manage account numbers content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: The calling key has no associated account content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' /api/v1/account/verify-phone/confirm: post: tags: - Account summary: 'Verify a business number (step 2: confirm code)' description: 'Step 2: submit the code to verify and bind the number to your account (api_account_phones). A bound business number raises pooled limits, unlocks the accountable report lane, and enables pre-call enrollment. Free accounts may bind exactly one number; a number already claimed by another account is rejected (the Sybil floor). Account-level keys only.' operationId: accountVerifyPhoneConfirm security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: - phone - code properties: phone: type: string description: The same number passed to /start, E.164. example: '+14155550142' code: type: string description: The one-time code that was delivered. example: '123456' responses: '200': description: Number verified and bound content: application/json: schema: type: object properties: ok: type: boolean verified_phone: type: string account_id: type: string format: uuid already_bound: type: boolean description: True when the number was already bound to this account (idempotent re-confirm). '400': description: Bad, expired, or exhausted code, or invalid phone content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '402': description: Free accounts can bind one phone number. Verify a number (personal $9 or business $29/yr) to bind additional numbers. content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: A tenant sub-key cannot manage account numbers content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: The calling key has no associated account content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: This number is already claimed by another account content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/account/tenants: get: tags: - MSP summary: List tenants description: List the calling account's tenants, each with a trailing-30-day usage rollup and its sub-key count. For MSPs and PBX resellers managing many downstream customers under one prepaid balance. Only ACCOUNT-LEVEL keys (keys not themselves scoped to a tenant) may manage tenants — a tenant sub-key cannot. operationId: listTenants security: - ApiKeyAuth: [] - BearerAuth: [] responses: '200': description: Tenants with usage rollups. content: application/json: schema: type: object properties: tenants: type: array items: $ref: '#/components/schemas/TenantSummary' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: The calling key is a tenant sub-key and may not manage tenants. content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: The calling key has no associated account. content: application/json: schema: $ref: '#/components/schemas/Error' post: tags: - MSP summary: Create a tenant description: Create a tenant (a downstream customer/site) under the calling account. Tenants exist so per-customer usage, billing rollups, rate limits, and suppression lists are attributed separately while all spend draws on the one account balance. Requires an account-level key. operationId: createTenant security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: - name properties: name: type: string minLength: 1 maxLength: 120 description: Human-readable tenant name (e.g. the downstream customer or site). example: Dental office responses: '201': description: Tenant created. content: application/json: schema: type: object properties: tenant: $ref: '#/components/schemas/Tenant' next: type: string description: Suggested next call. example: POST /api/v1/account/tenants/{id}/keys to issue this tenant a sub-key. '400': description: Invalid request (missing/empty name, or longer than 120 chars). content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: The calling key is a tenant sub-key and may not manage tenants. content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: A tenant with this name already exists on the account. content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/account/tenants/{id}: get: tags: - MSP summary: Get tenant detail description: 'Return one tenant''s detail: trailing-30-day usage rollup, its sub-keys (display fields only — never raw keys or hashes), and its suppression-list count and labels. Requires an account-level key.' operationId: getTenant security: - ApiKeyAuth: [] - BearerAuth: [] parameters: - name: id in: path required: true description: Tenant id (UUID). schema: type: string format: uuid responses: '200': description: Tenant detail with usage, keys, and suppression summary. content: application/json: schema: type: object properties: tenant: $ref: '#/components/schemas/Tenant' usage_30d: $ref: '#/components/schemas/TenantUsage' keys: type: array description: The tenant's sub-keys (display fields only). items: $ref: '#/components/schemas/TenantKey' suppressions: type: object properties: count: type: integer entries: type: array items: $ref: '#/components/schemas/Suppression' description: Labels + timestamps only — suppressed numbers are stored as hashes and are not recoverable. '400': description: Invalid tenant id (not a UUID). content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: The calling key is a tenant sub-key and may not manage tenants. content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Tenant not found on this account. content: application/json: schema: $ref: '#/components/schemas/Error' patch: tags: - MSP summary: Set tenant status description: Enable or disable a tenant. Disabling a tenant also disables all of its sub-keys, which then fail authentication; re-enabling restores them. Requires an account-level key. operationId: setTenantStatus security: - ApiKeyAuth: [] - BearerAuth: [] parameters: - name: id in: path required: true description: Tenant id (UUID). schema: type: string format: uuid requestBody: required: true content: application/json: schema: type: object required: - status properties: status: type: string enum: - active - disabled description: New tenant status. example: disabled responses: '200': description: Tenant status updated. content: application/json: schema: type: object properties: tenant: type: object properties: id: type: string format: uuid name: type: string status: type: string enum: - active - disabled note: type: string description: What the status change did to the tenant's sub-keys. '400': description: Invalid tenant id or status (must be "active" or "disabled"). content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: The calling key is a tenant sub-key and may not manage tenants. content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Tenant not found on this account. content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/account/tenants/{id}/keys: post: tags: - MSP summary: Issue a tenant sub-key description: Mint an API key scoped to one tenant. The sub-key inherits the issuing account's tier (an account that has topped up issues metered sub-keys; a free-tier account issues free sub-keys) and bills against the account's single prepaid balance. Sub-keys can perform lookups only — they are PBX credentials, not account credentials, and cannot manage tenants or top up. The raw key is returned EXACTLY ONCE and is never recoverable. Requires an account-level key; the tenant must be active. operationId: issueTenantKey security: - ApiKeyAuth: [] - BearerAuth: [] parameters: - name: id in: path required: true description: Tenant id (UUID). schema: type: string format: uuid requestBody: required: false content: application/json: schema: type: object properties: name: type: string maxLength: 120 description: Optional key name; defaults to " key". example: Front desk PBX responses: '201': description: Sub-key issued; raw key returned once. content: application/json: schema: type: object properties: tenant_id: type: string format: uuid api_key: type: string description: The raw key — shown ONLY here, never again. example: nol_8f3c2a1b9d4e6f7a8b9c0d1e2f3a4b5c6d7e8f9a key_prefix: type: string description: Non-secret display prefix. example: nol_8f3c2a1b name: type: string tier: type: string enum: - free - standard - enterprise rate_limit: type: integer description: Requests per 60-second window. allowed_use_cases: type: array items: type: string example: - lookup note: type: string description: Key-handling guidance. '400': description: Invalid tenant id (not a UUID). content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: The calling key is a tenant sub-key and may not manage tenants. content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Tenant not found on this account. content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: Tenant is disabled — re-enable it before issuing keys. content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/account/tenants/{id}/suppressions: get: tags: - MSP summary: List tenant suppressions description: 'List a tenant''s suppression entries: labels + timestamps and a count. Numbers are stored only as SHA-256 hashes (platform privacy rule) and are never returned — keep your own list and use `label` as your reference. A suppressed number gets no enrichment (no CNAM dip, no spam score) and no charge when looked up through this tenant''s sub-keys. Requires an account-level key.' operationId: listTenantSuppressions security: - ApiKeyAuth: [] - BearerAuth: [] parameters: - name: id in: path required: true description: Tenant id (UUID). schema: type: string format: uuid responses: '200': description: Suppression labels and count (never the numbers). content: application/json: schema: type: object properties: tenant_id: type: string format: uuid count: type: integer entries: type: array items: $ref: '#/components/schemas/Suppression' note: type: string example: Suppressed numbers are stored as SHA-256 hashes; only your labels are listed. '400': description: Invalid tenant id (not a UUID). content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: The calling key is a tenant sub-key and may not manage tenants. content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Tenant not found on this account. content: application/json: schema: $ref: '#/components/schemas/Error' post: tags: - MSP summary: Add a suppression description: Add a number to the tenant's suppression list. The number is canonicalized through libphonenumber before its hash is stored, so it matches what the lookup path checks. A suppressed number returns no enrichment and is not billed for this tenant. Requires an account-level key. operationId: addTenantSuppression security: - ApiKeyAuth: [] - BearerAuth: [] parameters: - name: id in: path required: true description: Tenant id (UUID). schema: type: string format: uuid requestBody: required: true content: application/json: schema: type: object required: - number properties: number: type: string description: Number to suppress (E.164 recommended). example: '+14155552671' label: type: string maxLength: 120 description: Optional reference label (the only field returned when listing — the number itself is hashed). example: front desk responses: '201': description: Suppression added. content: application/json: schema: type: object properties: tenant_id: type: string format: uuid suppressed: type: boolean example: true label: type: string nullable: true '400': description: Invalid tenant id, or "number" is not a valid E.164 number. content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: The calling key is a tenant sub-key and may not manage tenants. content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Tenant not found on this account. content: application/json: schema: $ref: '#/components/schemas/Error' delete: tags: - MSP summary: Remove a suppression description: Remove a number from the tenant's suppression list. Submit the same number; it is canonicalized the same way before its hash is matched. Requires an account-level key. operationId: removeTenantSuppression security: - ApiKeyAuth: [] - BearerAuth: [] parameters: - name: id in: path required: true description: Tenant id (UUID). schema: type: string format: uuid requestBody: required: true content: application/json: schema: type: object required: - number properties: number: type: string description: Number to un-suppress (E.164 recommended). example: '+14155552671' responses: '200': description: Suppression removed. content: application/json: schema: type: object properties: tenant_id: type: string format: uuid suppressed: type: boolean example: false '400': description: Invalid tenant id, or "number" is not a valid E.164 number. content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: The calling key is a tenant sub-key and may not manage tenants. content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: Tenant not found on this account. content: application/json: schema: $ref: '#/components/schemas/Error' /api/health: get: tags: - System summary: Health check description: Liveness/readiness probe used by the container orchestrator and load balancer. NO AUTH REQUIRED. Returns 200 with a small status body when the service is up. operationId: healthCheck responses: '200': description: Service is healthy content: application/json: schema: type: object properties: status: type: string example: ok db: type: string enum: - up - down example: up version: type: string example: v1.0.0 time: type: string format: date-time '503': description: Service degraded (database unreachable) content: application/json: schema: type: object properties: status: type: string example: degraded db: type: string example: down version: type: string time: type: string format: date-time /api/v1/mcp: post: tags: - MCP summary: MCP server (Streamable HTTP, JSON-RPC 2.0) description: 'Model Context Protocol endpoint for AI voice agents. Stateless, read-only. Speaks JSON-RPC 2.0 — initialize / notifications/initialized / ping / tools/list / tools/call. Tools: phone_lookup, line_type, caller_risk, dnc_check, reassigned_check (all annotated readOnlyHint). dnc_check and reassigned_check are preview tools that return "unknown" (and are unbilled) until a licensed data partner is configured. Register it in Vapi as an MCP tool with metadata.protocol="shttp"; Retell/Pipecat/LiveKit can also call it. tools/call requires a key with the "mcp" use case (Authorization: Bearer); initialize/ping/tools/list are public discovery. Each billable tool call meters the bundled mcp_call rate ($0.015); all output is a supplementary, low-confidence signal — the agent keeps every routing and dialing decision. This endpoint is response-only (no server-initiated SSE): GET returns 405.' operationId: mcpRpc security: - BearerAuth: [] - ApiKeyAuth: [] requestBody: required: true content: application/json: schema: type: object required: - jsonrpc - method properties: jsonrpc: type: string enum: - '2.0' id: description: Request id (omit for notifications). method: type: string example: tools/call params: type: object example: name: phone_lookup arguments: number: '+14155552671' responses: '200': description: JSON-RPC response (result, or a tools/call result envelope). '202': description: Accepted notification (no body). '400': description: Parse error / invalid request / unsupported MCP-Protocol-Version. '401': description: tools/call without a valid "mcp"-scoped key. '429': $ref: '#/components/responses/RateLimited' /api/v1/integrations/retell/inbound: post: tags: - Webhooks summary: Retell call_inbound webhook description: 'Adapter for Retell''s call_inbound webhook. Looks up the inbound caller and returns dynamic_variables (caller_name, caller_line_type, caller_spam_score, caller_risk, caller_risk_model, caller_on_dnc, caller_reassigned, caller_signal) for the agent prompt, plus a receipt_id in metadata. ALWAYS returns HTTP 200 with a (possibly empty) variables block — a non-2xx would keep the caller ringing — so auth failure, a missing number, or a supplier timeout degrade to neutral variables (fail-open on the live-call path). Authenticate with a key via Authorization: Bearer or ?key= on the webhook URL.' operationId: retellInbound security: - BearerAuth: [] - ApiKeyAuth: [] - CidQueryKeyAuth: [] requestBody: required: true content: application/json: schema: type: object properties: event: type: string example: call_inbound call_inbound: type: object properties: from_number: type: string example: '+14155552671' to_number: type: string example: '+14155550100' responses: '200': description: Retell dynamic variables (always 200, fail-open). content: application/json: schema: type: object properties: call_inbound: type: object properties: dynamic_variables: type: object additionalProperties: type: string metadata: type: object /api/v1/integrations/vapi/tool: post: tags: - Webhooks summary: Vapi custom (function) tool webhook description: 'Adapter for a Vapi custom function tool (an alternative to pointing Vapi at /api/v1/mcp). Accepts Vapi''s {message:{type:"tool-calls", toolCallList:[{id, arguments:{number}}]}} and returns {results:[{toolCallId, result}]} where result is the JSON-stringified phone_lookup bundle. Each call meters the bundled mcp_call rate. Fail-open: a bad argument or supplier hiccup yields a graceful result string. Authenticate with a key via Authorization: Bearer or ?key=.' operationId: vapiTool security: - BearerAuth: [] - ApiKeyAuth: [] - CidQueryKeyAuth: [] requestBody: required: true content: application/json: schema: type: object properties: message: type: object properties: type: type: string example: tool-calls toolCallList: type: array items: type: object properties: id: type: string arguments: type: object properties: number: type: string example: '+14155552671' responses: '200': description: Vapi tool results (one per toolCallId). content: application/json: schema: type: object properties: results: type: array items: type: object properties: toolCallId: type: string result: type: string /api/v1/receipts/{id}: get: tags: - Receipts summary: Retrieve a signed lookup receipt description: 'Fetch a signed, privacy-safe receipt by its unguessable id (the id is the capability — no API key needed, so TCPA-defense evidence can be shared with counsel). PII-free: the number appears only as number_hash. Verify response_signature over signed_payload with the Ed25519 key from GET /api/v1/publickey, then recompute sha256(your number) and match it against number_hash. A supplementary signal, not a compliance assertion.' operationId: getReceipt parameters: - name: id in: path required: true schema: type: string example: nol_rec_8sd91kfh20aJ responses: '200': description: The signed receipt. content: application/json: schema: $ref: '#/components/schemas/Receipt' '404': description: No receipt with that id. content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' /api/v1/sbc/redirect: post: tags: - SBC / SIP summary: SBC / SIP redirect decision description: 'Call-setup decision for a SIP redirect server / SBC (Kamailio, OpenSIPS, dSIPRouter, Sansay, Oracle/Acme Packet, ProSBC), consumed by the operator-run shim recipe under /integrations. Given the calling number, returns a ClearIP-compatible decision the shim maps to a SIP final response: decision=block → 603 Decline; decision=redirect → 302 (the operator supplies the Contact); decision=allow|flag → the operator allow code (503 default, or 404 route-advance). `sip.code` is the exact recommended code. Requires a key with the `sbc_redirect` use case (every lookup-entitled key has it, backfilled). Billed per decision on the standard tier ($0.010); free tier is rate-limited. A 603 BLOCK is only ever a deterministic/authoritative fact (invalid number, or DNC listed / reassigned from a configured partner) — the low-confidence spam signal can only raise a flag/redirect. FAIL-OPEN on the call path: a timeout or error returns decision=allow rather than an error. AUTH fails closed (incl. opt-in operator HMAC signing via X-Operator-* headers; see GET /api/v1/account/signing). Every value is a supplementary signal — the SBC keeps every routing decision.' operationId: sbcRedirect security: - ApiKeyAuth: [] - BearerAuth: [] - CidQueryKeyAuth: [] parameters: - name: Idempotency-Key in: header required: false description: Optional client-supplied request id for at-most-once billing on retries. schema: type: string - name: X-SBC-Budget-Ms in: header required: false description: The shim’s own call-setup deadline (ms). We never bill a decision that overran it. Capped at 5000. schema: type: integer requestBody: required: true content: application/json: schema: type: object required: - number properties: number: type: string description: The calling number (E.164 recommended). example: '+14155552671' called_number: type: string description: The dialed/destination number (optional, reserved). example: '+14155550100' verstat: type: string description: STIR/SHAKEN verstat passthrough; same accepted forms as /api/v1/lookup. example: TN-Validation-Passed allow_code: type: integer enum: - 503 - 404 default: 503 description: 'SIP code for allow/route-advance: 503 (ClearIP default) or 404 (Oracle/Acme, Ribbon, Metaswitch).' spam_threshold: type: integer minimum: 1 maximum: 99 default: 80 description: spam_score at/above which the decision becomes `flag` (advisory). redirect_threshold: type: integer minimum: 1 maximum: 99 description: 'Opt-in: spam_score at/above which the decision becomes `redirect` (302 auto-divert). Omit to disable.' block_reassigned: type: boolean default: false description: Treat reassigned `yes` (from a configured partner) as a block. block_invalid: type: boolean default: true description: Block an unparseable/invalid calling number (deterministic). budget_ms: type: integer description: Alternative to the X-SBC-Budget-Ms header. responses: '200': description: Supplementary redirect decision (uniform shape for known/unknown/valid/invalid numbers). content: application/json: schema: $ref: '#/components/schemas/SbcRedirectResponse' '400': description: Missing/empty number or malformed JSON. content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required (or a required request signature was missing/invalid). content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' /api/v1/compliance/evidence: get: tags: - SBC / SIP summary: FCC robocall-mitigation evidence bundle description: A signed, PII-free, independently-verifiable RECORD of the supplementary number-status checks this account performed over a window — aggregated from signed lookup receipts. An operator can attach it to / reference it in their OWN robocall-mitigation program documentation (47 CFR 64.6305, "analytics systems used" / "reasonable steps"). It is NOT an FCC certification, NOT a compliance determination, and does NOT make anyone "compliant" — the operator signs their own attestation. Numbers appear only as hashes. Account-level keys only; auth fails closed. Verify each receipt’s signature, the bundle signature, and the Merkle root against GET /api/v1/publickey. operationId: complianceEvidence security: - ApiKeyAuth: [] - BearerAuth: [] parameters: - name: from in: query required: false description: 'Window start (ISO 8601). Default: 365 days ago.' schema: type: string format: date-time - name: to in: query required: false description: 'Window end (ISO 8601). Default: now. Window capped at 400 days.' schema: type: string format: date-time responses: '200': description: The signed evidence bundle. content: application/json: schema: $ref: '#/components/schemas/EvidenceBundle' '400': description: Invalid window. content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required. content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Tenant sub-keys cannot export evidence; use an account-level key. content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/account/signing: get: tags: - SBC / SIP summary: Operator HMAC signing secret (for the calling key) description: 'Operator-grade HMAC request signing (Phase 4.3) for the calling key. Returns the key’s HKDF-derived signing secret (exposed only to the holder of the key — equivalent exposure to the key itself), the canonical scheme, and whether signing is currently required. The secret is never stored; it is re-derived on demand. signing_secret is null when the deployment has no signing master configured. Also reachable at the resource-homed alias /api/v1/account/keys/self/signing. Deliberately NOT gated on the manage use case: a narrowed, signing-locked key must always reach its own signing config.' operationId: getSigning security: - ApiKeyAuth: [] - BearerAuth: [] responses: '200': description: Signing state + secret for the calling key. content: application/json: schema: $ref: '#/components/schemas/SigningInfo' '400': description: Not available for dev-fallback keys. content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required. content: application/json: schema: $ref: '#/components/schemas/Error' post: tags: - SBC / SIP summary: Enable/disable HMAC signing on the calling key description: Toggle require_signed_requests on the calling key. When enabled, signed surfaces (e.g. /api/v1/sbc/redirect) require a valid X-Operator-Signature on this key. This management route is never itself signature-gated, so a key can always disable signing or re-fetch its secret. operationId: setSigning security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: - enabled properties: enabled: type: boolean responses: '200': description: Updated signing state + secret. content: application/json: schema: $ref: '#/components/schemas/SigningInfo' '400': description: Missing `enabled`, or a dev-fallback key. content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required. content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/publickey: get: tags: - Receipts summary: Ed25519 signing public key description: The Ed25519 public key (SPKI PEM) used to sign inbound-lookup responses and lookup receipts, so any verifier can check a signature without out-of-band key exchange. NO AUTH. public_key_pem is null when no signing key is configured (responses ship "unsigned"). operationId: publicKey responses: '200': description: The public key. content: application/json: schema: type: object properties: algorithm: type: string example: ed25519 public_key_pem: type: string nullable: true example: '-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n' /api/v1/report: post: tags: - Community reporting summary: Report a number (community sensor) description: 'The give-to-get half of the community sensor. Report a number with one or more tags using a single-use `receipt_id` from a prior POST /api/v1/inbound/lookup on the same number. Tags only — no free-text body. The receipt is an anti-replay nonce and rate control, NOT proof a call happened (it is self-mintable). Accounts with a verified business number report in the ACCOUNTABLE lane (reporter-weighted, earns credibility); free/personal accounts report in the CROWD lane (a bounded, deferring, credibility-firewalled supplementary signal that cannot sink a verified number on its own). You cannot report a number bound to your own account. The daily report quota is pooled per account: 10/day on the free floor, +100/day per verified business number. Requires an account-level key with the `report` use case (tenant sub-keys cannot report). Returns `{ ok, lane, report_id, provenance }` (provenance is a call-provenance label — matched / mismatched / null — for reports about an enrolled caller); a stale, used, or mismatched receipt returns 409, and an over-quota request returns 429.' operationId: reportNumber security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: - e164 - tags - receipt_id properties: e164: type: string description: The number to report (E.164 recommended). example: '+14155552671' tags: type: array items: type: string minItems: 1 description: One or more report tags (e.g. `robocall`). Tags only — free text is not accepted. example: - robocall receipt_id: type: string description: A single-use, anti-replay receipt nonce from a prior POST /api/v1/inbound/lookup on this same number. Spent on success; it is not proof the call occurred. example: nol_rec_… responses: '200': description: Report accepted. content: application/json: schema: type: object properties: schema_version: type: string example: '2026-06-13' ok: type: boolean example: true lane: type: string enum: - accountable - crowd report_id: type: string nullable: true format: uuid provenance: type: string nullable: true enum: - matched - mismatched description: 'Call-provenance label for an enrolled caller: `matched` (a pre-call edge confirms the reported caller looked up this receiver before the call), `mismatched` (enrolled caller + known receiver but no edge → the complaint may concern a spoofed call), or `null` (no enrolled-caller signal / not applicable). A supplementary signal — not proof.' '400': description: Invalid request (missing/invalid `e164`, no valid tag, or missing `receipt_id`). content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: Tenant sub-keys cannot report, or the number is bound to your own account. content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: The calling key has no associated account (legacy/internal key). content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: Invalid, already-used, or mismatched `receipt_id`. Do a fresh POST /api/v1/inbound/lookup on this number first. content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' /api/parse: post: tags: - Parsing summary: Parse a phone number (legacy alias) deprecated: true description: Frozen permanent alias of `POST /api/v1/parse`, kept for existing integrations — same behavior, but wrapped in the legacy `{success:}` envelope. New integrations should use the v1 path. Parse and validate a single phone number, returning comprehensive information including format variants, country, type, and validity. operationId: parsePhoneNumber security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: - phoneNumber properties: phoneNumber: type: string description: The phone number to parse (E.164 format recommended) example: '+14155552671' defaultCountry: type: string description: Default country code (ISO 3166-1 alpha-2) for numbers without country code example: US responses: '200': description: Successfully parsed phone number content: application/json: schema: $ref: '#/components/schemas/ParsedPhoneNumber' '400': description: Invalid request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: Rate limit exceeded content: application/json: schema: $ref: '#/components/schemas/Error' /api/parse/bulk: post: tags: - Parsing summary: Parse multiple phone numbers (legacy alias) deprecated: true description: Frozen permanent alias of `POST /api/v1/parse/batch`, kept for existing integrations — same behavior, but wrapped in the legacy `{success:}` envelope. New integrations should use the v1 path. Parse and validate multiple phone numbers in a single request. Maximum 100 numbers per request. operationId: parsePhoneNumbersBulk security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: - phoneNumbers properties: phoneNumbers: type: array items: type: string maxItems: 100 description: Array of phone numbers to parse example: - '+14155552671' - '+442071234567' - '+33123456789' defaultCountry: type: string description: Default country code for numbers without country code example: US responses: '200': description: Successfully parsed phone numbers content: application/json: schema: type: object properties: success: type: boolean count: type: integer results: type: array items: $ref: '#/components/schemas/ParsedPhoneNumber' summary: type: object properties: total: type: integer valid: type: integer invalid: type: integer '400': description: Invalid request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' /api/countries: get: tags: - Reference summary: List supported countries (legacy alias) deprecated: true description: Frozen permanent alias of `GET /api/v1/countries`, kept for existing integrations — same data, but wrapped in the legacy `{success:}` envelope. New integrations should use the v1 path. Get a list of all supported countries with their calling codes and example phone numbers. operationId: listCountries security: - ApiKeyAuth: [] - BearerAuth: [] responses: '200': description: List of supported countries content: application/json: schema: type: object properties: success: type: boolean count: type: integer countries: type: array items: $ref: '#/components/schemas/Country' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' /api/scrub: post: tags: - Trust summary: Scrub a calling list (legacy alias) deprecated: true description: Frozen permanent alias of `POST /api/v1/scrub`, kept for existing integrations (this one already uses the bare-object dialect; the v1 path adds `schema_version` and is where new fields land). New integrations should use the v1 path. Call-center list cleaning (API-key gated). Submit up to 1,000 numbers and an optional channel; for each, receive SUPPRESS (verified owner has a suppression preference — remove it), NO_MATCH (no suppression preference on record), or UNKNOWN (not resolvable). A compliance aid, not a legal determination; NO_MATCH is not consent. operationId: scrubList security: - ApiKeyAuth: [] - BearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: - numbers properties: numbers: type: array items: type: string maxItems: 1000 example: - '+16285550177' - '+12135559988' channel: type: string enum: - voice - sms - whatsapp default: voice description: Contact channel to check the suppression preference for. responses: '200': description: Per-number suppression status content: application/json: schema: type: object properties: count: type: integer channel: type: string enum: - voice - sms - whatsapp disclaimer: type: string summary: type: object properties: total: type: integer suppress: type: integer description: Numbers with a suppression preference on record no_match: type: integer description: Numbers with no suppression preference (includes opt-ins) unknown: type: integer description: Numbers that could not be resolved results: type: array items: type: object properties: input: type: string e164: type: string status: type: string enum: - SUPPRESS - NO_MATCH - UNKNOWN '400': description: Invalid request content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/RateLimited' components: securitySchemes: ApiKeyAuth: type: apiKey in: header name: X-API-Key description: API key for authentication BearerAuth: type: http scheme: bearer description: Bearer token authentication CidQueryKeyAuth: type: apiKey in: query name: key description: API key in the `?key=` query param. Accepted by the header-less PBX endpoint GET /api/v1/cid/{number} and by the webhook adapters POST /api/v1/integrations/retell/inbound, POST /api/v1/integrations/vapi/tool, and POST /api/v1/sbc/redirect, whose upstream platforms set only a static webhook URL and cannot send an Authorization/X-API-Key header. The key can leak into access logs — use a dedicated, rotated key, and prefer header auth wherever the client supports it. responses: InsufficientBalance: description: Standard-tier prepaid balance is exhausted. Top up (POST /api/v1/account/topup) to resume. content: application/json: schema: $ref: '#/components/schemas/InsufficientBalance' RateLimited: description: Per-key rate limit exceeded. Retry after the number of seconds in the Retry-After header. headers: Retry-After: description: Seconds to wait before retrying. schema: type: integer content: application/json: schema: $ref: '#/components/schemas/Error' schemas: LaneUsd: type: object description: A min/avg/max spread for one pricing lane, as decimal USD strings. properties: min_usd: type: string example: '0.0072' avg_usd: type: string example: '0.0146' max_usd: type: string example: '0.32' ProviderCost: type: object description: 'One named, publicly-displayable provider''s slice of the aggregate. Anonymous sources never appear here — they stay inside the channel-level numbers only. The lane fields present depend on the channel: a voice cost carries `international`/`local`, an SMS cost carries `person`/`application`; each is null when that provider''s decks do not split that way.' properties: name: type: string example: DIDWW domain: type: string nullable: true example: didww.com min_usd: type: string example: '0.0072' avg_usd: type: string example: '0.0146' max_usd: type: string example: '0.32' international: allOf: - $ref: '#/components/schemas/LaneUsd' nullable: true description: 'Voice: international lanes (default + origin-based).' local: allOf: - $ref: '#/components/schemas/LaneUsd' nullable: true description: 'Voice: in-country lanes.' person: allOf: - $ref: '#/components/schemas/LaneUsd' nullable: true description: 'SMS: person-originated (P2P) decks.' application: allOf: - $ref: '#/components/schemas/LaneUsd' nullable: true description: 'SMS: application-originated (A2P) decks.' ChannelCost: type: object description: 'Indicative RETAIL cost to reach a destination on ONE channel, aggregated across provider price-list decks. All amounts are decimal USD strings (money is integer micro-USD internally). `unit` says whether the figures are per minute (voice) or per message (sms). The channel-relevant lane fields (voice: `international`/`local`; sms: `person`/`application`) appear at the aggregate level and per provider; `breakdown` lists only publicly-displayable providers (cheapest avg first), with anonymous sources folded into the aggregate numbers only.' properties: unit: type: string enum: - per_minute - per_message min_usd: type: string example: '0.0072' avg_usd: type: string example: '0.0146' max_usd: type: string example: '0.32' network_type: type: string nullable: true description: The line-type filter that was applied (e.g. `mobile`, `premium`), or null when unfiltered. example: mobile providers: type: integer description: Distinct providers behind the aggregate (named + anonymous). example: 3 rate_count: type: integer description: Underlying rate entries aggregated. example: 5 international: allOf: - $ref: '#/components/schemas/LaneUsd' nullable: true description: 'Voice only: cross-provider international-lane aggregate.' local: allOf: - $ref: '#/components/schemas/LaneUsd' nullable: true description: 'Voice only: cross-provider in-country-lane aggregate.' person: allOf: - $ref: '#/components/schemas/LaneUsd' nullable: true description: 'SMS only: cross-provider P2P aggregate.' application: allOf: - $ref: '#/components/schemas/LaneUsd' nullable: true description: 'SMS only: cross-provider A2P aggregate.' breakdown: type: array items: $ref: '#/components/schemas/ProviderCost' RiskView: type: object description: 'One risk vocabulary (added 2026-06-11, additive — no schema_version bumps): the same numeric signal as the surface''s legacy field, plus a band and the MODEL label that says which pipeline scored it. The legacy fields (`spam_score`, `risk_score`/`risk_level`) are frozen forever; this object is the disclosed, consistent read. A null score yields the uniform unknown shape ({score:null, level:"unknown", model:null}) on every branch (anti-enumeration; fail-open nulls are load-bearing). The numeric scales are deliberately NOT unified across models — `model` is what tells them apart. See "Risk models" in the spec intro.' properties: score: type: integer nullable: true description: The risk score on the MODEL's own scale (1–99 for first_party_plus_restricted_sources; 0–100 for the others); null when no signal. level: type: string enum: - low - medium - high - unknown description: 'Shared banding: <40 low, <70 medium, ≥70 high; unknown when score is null. NOTE: the SBC default flag threshold is a separate policy knob (80) — level high does not automatically flag.' model: type: string nullable: true enum: - first_party_plus_restricted_sources - first_party_plus_external - dial_structural - null description: Which scoring pipeline produced the score; null when score is null. AccountPhone: type: object description: One verified number bound to the calling account, with its call-provenance enrollment state. These are the only fields the resource exposes — the platform's internal review/monitoring columns are operator-only by design. properties: e164: type: string example: '+442071838750' verified_at: type: string format: date-time description: When the OTP bind verified this number onto the account. precall_enrolled: type: boolean precall_enrolled_at: type: string format: date-time nullable: true ParsedPhoneNumber: type: object properties: valid: type: boolean description: Whether the phone number is valid according to E.164 / international numbering rules possible: type: boolean description: Whether the phone number is possibly valid (less strict than valid) input: type: string description: The original input string e164: type: string nullable: true description: E.164 formatted number (e.g., +14155552671) national: type: string nullable: true description: National format (e.g., (415) 555-2671) international: type: string nullable: true description: International format (e.g., +1 415-555-2671) rfc3966: type: string nullable: true description: RFC3966 URI format (e.g., tel:+1-415-555-2671) countryCode: type: string nullable: true description: ISO 3166-1 alpha-2 country code countryCallingCode: type: string nullable: true description: Country calling code (e.g., 1 for US) nationalNumber: type: string nullable: true description: National number without country code type: type: string nullable: true enum: - MOBILE - FIXED_LINE - FIXED_LINE_OR_MOBILE - TOLL_FREE - PREMIUM_RATE - SHARED_COST - VOIP - PERSONAL_NUMBER - PAGER - UAN - VOICEMAIL description: Type of phone number carrier: type: string nullable: true description: Mobile carrier name (available for mobile numbers) location: type: string nullable: true description: Geographic location associated with the number timezones: type: array items: type: string nullable: true description: List of timezones for this phone number region uri: type: string nullable: true description: Dialable URI raw: type: object nullable: true description: Raw parsed data from the phone-number parser properties: country: type: string countryCallingCode: type: string nationalNumber: type: string number: type: string ext: type: string carrierCode: type: string error: type: string nullable: true description: Error message if parsing failed Country: type: object properties: code: type: string description: ISO 3166-1 alpha-2 country code name: type: string description: Country name callingCode: type: string description: Country calling code exampleNumber: type: string nullable: true description: Example phone number in E.164 format exampleNational: type: string nullable: true description: Example phone number in national format Error: type: object properties: success: type: boolean example: false description: Legacy field emitted ONLY by the pre-v1 parse family (/api/parse, /api/parse/bulk, /api/countries). /v1 routes return only `error` — do not depend on `success` there. error: type: string description: Human-readable error message (prose — switch on `code`, not on this string). code: type: string enum: - missing_key - invalid_key - use_case_forbidden - rate_limited_key - rate_limited_pool - rate_limited_ip - signature_invalid - insufficient_balance - account_suspended - paid_verification_required - receipt_invalid description: 'Stable machine-readable error code (added 2026-06-12, additive — older errors may omit it). See the "Error codes" section in the API description for the full table. A valid key on the wrong use case returns 403 use_case_forbidden (not 401): re-authing will not fix a permissions problem.' retry_after_seconds: type: integer description: 'Present on 429s: seconds until the window resets (mirrors the Retry-After header).' required: - error Account: type: object description: 'A self-service API account. Balances are in microdollars (1e-6 USD): $0.004 = 4,000, a $5 top-up = 5,000,000.' properties: id: type: string format: uuid email: type: string format: email nullable: true description: Optional renewal-reminder email (may be null). name: type: string nullable: true balance_micros: type: integer description: Remaining prepaid credit in microdollars. example: 5000000 balance_usd: type: string description: Same balance as a 2-decimal USD string. example: '5.00' status: type: string enum: - active - suspended example: active created_at: type: string format: date-time UsageSummary: type: object description: Aggregated usage over the trailing window (default 30 days). properties: requests: type: integer description: Total billed units in the window. example: 1280 billed_micros: type: integer description: Total billed amount over the window, in microdollars. example: 5120000 billed_usd: type: string description: Same billed amount as a 2-decimal USD string. example: '5.12' ApiKeyView: type: object description: Display-only view of an API key (§4.4). Raw key material and hashes are never returned. properties: id: type: string format: uuid key_prefix: type: string example: nol_8f3c2a1b name: type: string tenant_id: type: string format: uuid nullable: true description: Non-null for tenant sub-keys. tier: type: string enum: - free - standard - enterprise allowed_use_cases: type: array items: type: string rate_limit: type: integer description: Requests per 60-second window. requests_total: type: integer last_used_at: type: string format: date-time nullable: true disabled: type: boolean require_signed_requests: type: boolean rotation_grace_expires_at: type: string format: date-time nullable: true description: Non-null while a rotation grace window is open (the previous key still validates until then). created_at: type: string format: date-time InsufficientBalance: type: object description: 402 body returned when a standard-tier account has no remaining credit for a billed request. A SUSPENDED account instead returns 403 with code `account_suspended` and no top-up pointer (payment does not lift a suspension). properties: error: type: string example: Insufficient prepaid balance for this request. code: type: string enum: - insufficient_balance description: Stable machine-readable code (added 2026-06-12). balance_micros: type: integer nullable: true description: Remaining balance in microdollars (may be 0 or null). example: 0 topup: type: string description: How to add credit (prose; prefer the structured siblings). example: 'POST /api/v1/account/topup with {"amount_cents": 500} (minimum $5) to add credit.' topup_url: type: string example: /api/v1/account/topup description: Top-up endpoint path. topup_min_cents: type: integer example: 500 description: Minimum top-up amount in cents. InboundLookupResponse: type: object properties: schema_version: type: string example: '2026-05-31' result: type: string enum: - found - no_record number: type: string identity_type: type: string enum: - verified_business - verified_individual - unverified - unknown display_label: type: string description: Business name, or "Verified & online" for individuals — never a personal name. profile_url: type: string nullable: true description: Business profile URL only; null for individuals and unknown. personal_details_exposed: type: boolean example: false risk_score: type: integer nullable: true description: 0-100, higher = worse; null when identity_type is unknown. FROZEN field name — `risk` is the disclosed read. risk_level: type: string enum: - low - medium - high - unknown risk: $ref: '#/components/schemas/RiskView' signals: type: array items: type: string description: PII-free evidence labels. recommended_action: type: string enum: - allow - label - challenge_or_route - block_candidate - allow_with_default_policy ttl_seconds: type: integer receipt_id: type: string example: nol_rec_… response_signature: type: string description: '''ed25519:'', or ''unsigned'' when no signing key is configured.' Receipt: type: object description: A signed lookup receipt (plan 3.3). No raw phone number is stored — only number_hash = sha256(E.164). Verify response_signature over the exact signed_payload bytes with the Ed25519 public key (GET /api/v1/publickey), then recompute sha256(your E.164) and match number_hash to bind the receipt to a number. Because a phone number is a small keyspace, number_hash is recomputable from a candidate number — treat the receipt id as bound to a specific number, not as anonymized, and share it only with parties entitled to know that number. A supplementary signal, not a compliance assertion. properties: receipt_id: type: string example: nol_rec_8sd91kfh20aJ schema_version: type: string nullable: true example: '2026-06-03' number_hash: type: string description: SHA-256 hex of the E.164 — the only number representation stored. line_type: type: string nullable: true example: mobile dnc_status: type: string nullable: true enum: - not_listed - listed - unknown description: Supplementary do-not-call signal; "unknown" until a data partner is configured. reassigned_status: type: string nullable: true enum: - 'no' - 'yes' - unknown context: type: string nullable: true example: mcp:dnc_check checked_at: type: string format: date-time nullable: true description: The "as of T" the receipt cryptographically binds. created_at: type: string format: date-time signed_payload: type: string nullable: true description: The exact canonical JSON that was signed (commits to number_hash). response_signature: type: string nullable: true description: '''ed25519:'', or ''unsigned''.' verification: type: object properties: algorithm: type: string example: ed25519 public_key_url: type: string example: https://numbers.online/api/v1/publickey instructions: type: string SbcRedirectResponse: type: object description: Supplementary SBC/SIP redirect decision (plan 4.2). Same shape for known/unknown/valid/invalid numbers (anti-enumeration). Every field is a low-confidence supplementary signal — the SBC keeps the routing decision; Numbers Online never asserts a call is lawful, unlawful, safe, or spam. properties: schema_version: type: string example: '2026-06-06' e164: type: string nullable: true example: '+14155552671' valid: type: boolean decision: type: string enum: - allow - flag - redirect - block description: Recommended action (supplementary). flag = advisory elevated risk (still an allow code). reason: type: string example: no_actionable_signal description: Machine-readable reason code for the decision (e.g. invalid_number, dnc_listed, risk_over_flag_threshold, latency_budget, error). sip: type: object description: The SIP final response the operator’s shim should emit. properties: code: type: integer enum: - 603 - 302 - 503 - 404 example: 503 description: 603 block · 302 redirect · 503/404 allow-route-advance. reason: type: string example: Service Unavailable redirect_target: type: string nullable: true description: Always null — the operator supplies the 302 Contact (screening/diversion target) in their own shim config. advisory: type: object properties: spam_score: type: integer nullable: true minimum: 1 maximum: 99 description: Low-confidence supplementary spam signal; null when unavailable. Never drives a block. FROZEN field name — `risk` is the disclosed read. risk: $ref: '#/components/schemas/RiskView' confidence: type: string enum: - low line_type: type: string nullable: true example: mobile verstat: type: string example: unknown dnc_status: type: string enum: - not_listed - listed - unknown reassigned_status: type: string enum: - 'no' - 'yes' - unknown signal: type: string enum: - supplementary provider: type: string example: numbers.online receipt_id: type: string nullable: true example: nol_rec_8sd91kfh20aJ insufficient_balance: type: boolean description: When true, the call is still ALLOWED on deterministic fields only (no fresh CNAM dip). Top up to restore full signal. as_of: type: string format: date-time EvidenceBundle: type: object description: A signed FCC robocall-mitigation evidence bundle (plan 4.5). PII-free (numbers only as hashes). A record of supplementary checks — NOT an FCC certification or compliance determination. properties: schema_version: type: string example: '2026-06-06' bundle_id: type: string example: nol_bundle_… operator: type: object properties: account_id: type: string nullable: true key_prefix: type: string nullable: true window: type: object properties: from: type: string format: date-time to: type: string format: date-time generated_at: type: string format: date-time totals: type: object description: 'Aggregate counts: checks, distinct_numbers, by_dnc, by_reassigned, by_context.' merkle_root: type: string nullable: true description: SHA-256 Merkle root over the receipt leaves; null when the window held no receipts. receipts: type: array items: $ref: '#/components/schemas/Receipt' disclaimer: type: string response_signature: type: string description: '''ed25519:'' over the canonical bundle, or ''unsigned''.' truncated: type: boolean description: True when the window held more than the per-bundle receipt cap (disclosed, never silent). public_key_url: type: string example: https://numbers.online/api/v1/publickey verify: type: string SigningInfo: type: object description: Operator HMAC request-signing state + secret for the calling key (Phase 4.3). properties: signing_required: type: boolean scheme: type: string example: hmac-sha256 max_skew_seconds: type: integer example: 300 canonical: type: string example: METHOD\nPATH\nsha256(body)hex\nX-Operator-Timestamp\nX-Operator-Nonce headers: type: array items: type: string docs_url: type: string signing_secret: type: string nullable: true description: The HKDF-derived HMAC secret for this key; null when signing is not configured on the deployment. LookupResponse: type: object description: 'Uniform number-lookup result. The same shape is returned for valid, invalid, known, and unknown numbers (anti-enumeration). Invalid input yields `valid: false` with the deterministic fields (`e164`, `formatted`, `line_type`, `carrier`, `country`) null, `verstat` `unknown`, `confidence` `low`, and `spam_score` null. Enrichment fields (`cnam`, `spam_score`) are fail-open — they null out on a supplier/scoring error rather than failing the request.' properties: schema_version: type: string example: '2026-06-03' description: Shape version (date-stamped, unique per shape). Additive changes never bump it; remove/rename/retype does. e164: type: string nullable: true description: Canonical E.164 number, or null when the input is invalid. example: '+14155552671' valid: type: boolean description: Whether the input is a valid number. formatted: type: object description: Display formats; both null when invalid. properties: national: type: string nullable: true example: (415) 555-2671 international: type: string nullable: true example: +1 415-555-2671 line_type: type: string nullable: true description: Lowercased line type ('mobile', 'fixed_line', 'voip', …), or null when indeterminate. carrier: type: string nullable: true description: Carrier of the number RANGE (original allocation, NOT porting-aware) — a supplementary signal. country: type: string nullable: true description: ISO 3166-1 alpha-2 country code. example: US cnam: type: string nullable: true description: 'Caller name (CNAM), or null when unavailable or not dipped. Privacy carve-out: the name of an individual who has verified their personal number on Numbers Online is never returned (same invariant as the Inbound API''s ''Verified & online'' rule).' verstat: type: string enum: - verified - unverified - unknown description: Normalized STIR/SHAKEN verstat. `unknown` when no validation was performed or none was supplied. spam_score: type: integer nullable: true minimum: 1 maximum: 99 description: Supplementary low-confidence spam signal on a 1–99 scale (higher = riskier); null when no signal is available. FROZEN field name — `risk` is the disclosed read. risk: $ref: '#/components/schemas/RiskView' confidence: type: string enum: - low description: Confidence label for the supplementary signals — always `low`. cached: type: boolean description: True when CNAM was served from cache (no fresh supplier dip — billed at the cheaper rate). sources: type: object description: Per-field data provenance (resale transparency). properties: carrier: type: string nullable: true enum: - number_range_allocation - null description: Provenance of the carrier field. cnam: type: string nullable: true enum: - wholesale_cnam - cache - null description: Provenance of the CNAM field. spam_score: type: string nullable: true description: Provenance of the spam signal (e.g. 'baseline_prior' or a '+'-joined basis list); null when no signal. as_of: type: string format: date-time description: Timestamp the lookup was assembled. LookupBatchResponse: type: object description: 'Bulk lookup result: one entry per submitted number (input order) plus a billing summary.' properties: schema_version: type: string example: '2026-06-12' description: Version of the batch ENVELOPE (results/summary wrapper); each per-number result carries its own schema_version. results: type: array items: $ref: '#/components/schemas/LookupResponse' description: Per-number lookup results, in the order the numbers were submitted. summary: type: object properties: total: type: integer description: Numbers submitted. valid: type: integer description: Numbers that parsed as valid (includes tenant-suppressed numbers, which are valid but not billed). invalid: type: integer description: Numbers that were invalid (not billed). suppressed: type: integer description: 'Valid numbers on the calling tenant''s suppression list: returned with deterministic fields only (no enrichment) and not billed.' billed_fresh_cnam: type: integer description: Valid numbers billed at $0.004 (fresh wholesale CNAM dip). billed_enriched: type: integer description: Valid numbers billed at $0.002 (cache hit or no CNAM supplier). Tenant: type: object description: A tenant (downstream customer/site) under an MSP account. properties: id: type: string format: uuid name: type: string example: Dental office status: type: string enum: - active - disabled example: active created_at: type: string format: date-time TenantUsage: type: object description: A tenant's aggregated usage over the trailing 30 days. properties: requests: type: integer description: Total billed units in the window. example: 420 billed_micros: type: integer description: Total billed amount over the window, in microdollars. example: 1680000 fresh_cnam_dips: type: integer description: Lookups that performed a fresh wholesale CNAM dip ($0.004 each). cached_or_enriched: type: integer description: Lookups served without a fresh dip ($0.002 each). TenantSummary: type: object description: A tenant plus its sub-key count and trailing-30-day usage (list view). properties: id: type: string format: uuid name: type: string example: Dental office status: type: string enum: - active - disabled example: active created_at: type: string format: date-time keys: type: integer description: Number of sub-keys issued to this tenant. example: 2 usage_30d: $ref: '#/components/schemas/TenantUsage' TenantKey: type: object description: A tenant sub-key (display fields only — never the raw key or its hash). properties: id: type: string format: uuid key_prefix: type: string example: nol_8f3c2a1b name: type: string tier: type: string enum: - free - standard - enterprise rate_limit: type: integer description: Requests per 60-second window. requests_total: type: integer description: Lifetime request count for this sub-key. disabled: type: boolean Suppression: type: object description: A suppression-list entry. The suppressed number is stored only as a SHA-256 hash and is never returned — only its label and timestamp. properties: label: type: string nullable: true description: Your reference label for the entry. example: front desk created_at: type: string format: date-time