{"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\": \"<human prose>\", \"code\": \"<machine enum>\" }`,\nplus `retry_after_seconds` on 429s and `balance_micros`/`topup_url`/`topup_min_cents`\non 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"}},"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:<source_class>`.","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 \"<tenant name> 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:<base64>', 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:<base64>', 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:<base64>' 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"}}}}}}