# openappend — People Data API (full reference) Version: 2026-08-14 (rev 3). Brand: openappend.com. Contract: additive, versioned under /v1. Human docs: https://openappend.com/docs Concise summary: https://openappend.com/llms.txt Agent / MCP setup: https://openappend.com/agents A single JSON API over ~971M phones and ~380.8M people (telco identity records + B2C consumer profiles). Three endpoints cover the whole surface: GET /v1/fields "What can I filter and return?" Free POST /v1/people/count "How many people match?" Free POST /v1/people/search "Give me the matching people." Per record (or per contact revealed) Two shortcuts (/v1/email-to-phone, /v1/phone-to-email) cover common single-key reversals. Curated views (/v1/views/{name}) pre-bake common audiences. An MCP server wraps all of it for agent use. Every response carries a uniform envelope with a meta block (credits used + remaining, latency, request_id). Free count and fields are deliberate differentiators: dial filters for free, pay only for delivered data. ================================================================================ 1. PLATFORM ================================================================================ Base URL: https://api.openappend.com/v1 (REST + MCP) Keys: bearer keys prefixed oa_live_… / oa_test_…. A key determines its account, price book, rate limits, and which field CATEGORIES it may see. A key not entitled to a category never receives those fields; filtering on them returns 403 field_not_licensed. (Later, out of scope now: the backend is brand-agnostic; adding a white-label brand is a Cloudflare custom-domain route + a new key prefix, no code fork.) ================================================================================ 2. AUTHENTICATION ================================================================================ Every request carries a bearer key: Authorization: Bearer oa_live_9a3f1c0b6e2d4f80a1b2c3d4e5f60718 - Keys are issued out of band and shown once at creation. Only a SHA-256 hash is stored server-side, so a breach never leaks a usable key. - oa_test_… keys hit the same endpoints against a sandbox slice, never billed. - Missing/garbled key -> 401 missing_or_malformed_key. - Unknown or revoked key -> 401 invalid_key (the two are deliberately indistinguishable). ================================================================================ RESPONSE ENVELOPE ================================================================================ Every 2xx shares one shape. Payload under "data"; list endpoints add "page"; every response carries "meta". { "data": { … } | [ … ], "page": { // list endpoints only "limit": 100, "next_cursor": "eyJvIjoxMDB9", // null on the last page "total_estimate": 41230 // use /count for an exact number }, "meta": { "credits_used": 100, // charged by THIS call "credits_remaining": 4110, // balance AFTER this call "latency_ms": 41, "request_id": "req_9d3b2e77a010" // quote in support tickets; on errors too } } ================================================================================ 3. FIELD DISCOVERY — GET /v1/fields (FREE) ================================================================================ Returns the machine-readable catalog of every field you can filter on and request back, grouped by category, with operators and value domains. Treat this as the source of truth; never hard-code the field list — new fields appear here without a version bump. Request: curl https://api.openappend.com/v1/fields -H "Authorization: Bearer oa_live_…" Optional query params: category= Return only one category (e.g. category=phone). filterable=true Only fields usable in a filter. Response shape (abridged): object=field_list, version, categories[] each with id, label, and fields[] each: { name, type, filterable, returnable, operators[], plus range / values as applicable, description }. THE FIELD CATALOG (every field filterable unless noted). Financial and Property categories are plan-licensed per key. Identity: first_name string eq prefix exists middle_name string eq exists last_name string eq prefix exists age integer eq gte lte between (18–100) birth_year integer eq gte lte between gender enum eq in (male, female) Location: street string eq prefix (standardized address line) city string eq in state enum eq in (2-letter USPS) zip5 string eq in prefix (prefix enables ZIP3-ish radius targeting) county string eq in Demographics: marital_status enum eq in (married, single, unknown) children_present boolean eq length_of_residence integer gte lte between (years at address) Financial (plan-licensed): income_range ordinal eq gte lte between (banded) net_worth ordinal eq gte lte between (banded) credit_range ordinal eq gte lte between (banded) Property (plan-licensed): home_owner boolean eq home_value ordinal gte lte between (banded) dwelling_type enum eq in (single_family, multi, apartment, …) Phone: phone string eq (exact 10-digit reverse lookup) carrier string eq in exists line_type enum eq in (mobile, landline, voip) dnc boolean eq (Do-Not-Call flag) disconnected boolean eq phone_count integer gte lte (# phones on the record) Email: email string eq (exact reverse lookup) has_email boolean eq (record has >=1 email) has_phone boolean eq (record has >=1 phone) email_count integer gte lte Filtering a plan-licensed field without entitlement -> 403 field_not_licensed (param set). ================================================================================ 4. COUNTING — POST /v1/people/count (FREE) ================================================================================ Returns how many records match a filter set, returning no records. The audience-sizing step; the natural precursor to a search. Request: curl https://api.openappend.com/v1/people/count \ -H "Authorization: Bearer oa_live_…" -H "Content-Type: application/json" \ -d '{ "filters": { "state": { "in": ["IL","IN","WI"] }, "age": { "between": [30,55] }, "home_owner": true, "income_range": { "gte": "100k" }, "line_type": "mobile", "dnc": false } }' Response: { "data": { "count": 128450, "filters_echo": { … } }, "meta": { "credits_used": 0, "credits_remaining": 4210, "latency_ms": 22, "request_id": "req_…" } } Counts are exact within a data snapshot and may drift slightly between monthly refreshes. count is free — size as many times as you like before spending. ================================================================================ 5. SEARCHING — POST /v1/people/search (metered) ================================================================================ Returns matching records. Same filter grammar as count, plus result shaping (fields, limit, order_by) and cursor pagination. Body fields: filters (required) Filter object (see Filter grammar). fields all licensed Whitelist of fields to return. Fewer = smaller payload, same price. limit 100 Records per page. Max 1000. cursor — Opaque token from a prior page's next_cursor. order_by relevance ":asc|desc". Only on returnable scalar fields. reveal true false returns records with contacts gated at no per-contact charge. Request: curl https://api.openappend.com/v1/people/search \ -H "Authorization: Bearer oa_live_…" -H "Content-Type: application/json" \ -d '{ "filters": { "state":"IL","city":"Chicago","age":{"between":[30,55]}, "home_owner":true,"line_type":"mobile","dnc":false }, "fields": ["first_name","last_name","age","address","phones","emails"], "limit": 100, "order_by": "age:desc" }' Response (one record abridged): { "data": [ { "id": "hh_3f9c…", // stable household id, safe to dedup on "first_name": "John", "last_name": "Sterling", "age": 47, "gender": "male", "address": { "street":"1200 W Diversey Pkwy","city":"Chicago","state":"IL", "zip5":"60614","zip4":"2231","county":"Cook" }, "phones": [ { "number":"3125551234","line_type":"mobile","carrier":"T-Mobile","dnc":false,"disconnected":false }, { "number":"3125559876","line_type":"landline","carrier":"AT&T","dnc":true,"disconnected":false } ], "emails": [ { "address":"jsterling@example.com" } ], "demographics": { "income_range":"100000-149999","home_owner":true,"marital_status":"married" } } ], "page": { "limit":100,"next_cursor":"eyJvIjoxMDB9","total_estimate":41230 }, "meta": { "credits_used":100,"credits_remaining":4110,"latency_ms":41,"request_id":"req_…" } } Phones within a record are ranked CALLABLE-FIRST (live mobile -> live unknown -> live landline -> disconnected), so phones[0] is always the best number to dial. Every phone carries its live telco reality including the DNC flag — the differentiator over raw list data. With reveal:false, the same records come back but phones/emails are replaced by counts (phone_count, email_count); non-contact fields stay visible — qualify a record on demographics/geo before spending a credit to reveal its contacts. Pagination: cursor-based. Page until next_cursor is null. The cursor encodes a stable snapshot, so paging a large audience won't skip or duplicate records even as data refreshes. ================================================================================ 6. FILTER GRAMMAR ================================================================================ filters is an object keyed by field name. A bare scalar means equality; an operator object expresses ranges and sets. Fields AND together; use "in" for OR-within-a-field. { "filters": { "state": { "in": ["IL","IN"] }, "age": { "between": [30,55] }, "income_range": { "gte": "100k" }, "home_owner": true, "last_name": { "prefix": "St" }, "carrier": { "exists": true }, "dnc": false } } Operators: eq any { "state": { "eq": "IL" } } or just { "state": "IL" } in enum,string,zip { "state": { "in": ["IL","IN"] } } gte/lte integer,ordinal { "age": { "gte": 40 } } between integer,ordinal { "age": { "between": [30,55] } } (inclusive) prefix string,zip { "zip5": { "prefix": "606" } } exists any { "email": { "exists": true } } Ordinal fields (income_range, net_worth, credit_range, home_value) are BANDED, not continuous. They accept human labels or band edges; gte/lte/between compare by band order, not string: "income_range": { "gte": "100k" } // >= the $100k band "income_range": { "between": ["50k","150k"] } // the $50k–$150k bands inclusive "net_worth": { "gte": "500k" } Accepted band labels are enumerated per field in GET /v1/fields under "values". An unknown band -> 400 invalid_filter. ================================================================================ 7. SHORTCUT ENDPOINTS ================================================================================ Simplest path for the two most common single-key reversals. One credit per hit, misses free, responses scoped to ONLY the requested contact type (no identity or demographics). max_results defaults to 1, caps at 10. POST /v1/email-to-phone -> { "email": "jsmith@example.com", "max_results": 3 } { "ok": true, "hit": true, "phones": [ { "number":"3125551234","line_type":"mobile","carrier":"T-Mobile","dnc":false,"disconnected":false } ], "credits_charged": 1, "request_id": "req_…" } POST /v1/phone-to-email -> { "phone": "3125551234", "max_results": 3 } { "ok": true, "hit": true, "emails": [ { "address": "jsmith@example.com" } ], "credits_charged": 1, "request_id": "req_…" } ================================================================================ 7b. CURATED VIEWS — GET /v1/views/{name} ================================================================================ Named, pre-baked filter sets. Each view is exactly a search with its filters pre-applied; layer additional filters/fields/limit/cursor/reveal on top via query or body. Billing identical to search. callable-mobiles line_type=mobile, dnc=false, disconnected=false homeowners home_owner=true high-income income_range >= top bands reachable has_phone=true OR has_email=true renters home_owner=false, dwelling_type in [apartment, multi] Views also accept the flat query-param form of simple filters (state=IL, age_min=30, home_owner=true), so a basic pull is a single GET: curl -s "https://api.openappend.com/v1/views/callable-mobiles?state=IL&county=Cook&age_min=30&age_max=55" \ -H "Authorization: Bearer oa_live_…" The full operator grammar remains available via POST /v1/people/search for anything richer. ================================================================================ 8. ERRORS ================================================================================ Every error shares one envelope + an appropriate HTTP status. "param" names the offending field when the error is about a specific input. { "ok": false, "error": "invalid_filter", "message": "Unknown field 'incom_range'.", "param": "incom_range", "request_id": "req_…" } 400 bad_json Body isn't valid JSON. 400 invalid_filter Unknown field, bad operator, malformed value. param set. 401 missing_or_malformed_key No/garbled bearer key. 401 invalid_key Unknown or revoked key. 402 insufficient_credits Not enough credits for the request. 403 field_not_licensed Key not entitled to a requested/filtered field. param set. 403 scope_not_allowed Key can't call this endpoint. 429 rate_limited Per-key rate limit exceeded. Retry-After header set. 502 upstream_error Backend hiccup. NO credit charged — safe to retry. All 4xx and 5xx responses are FREE — failed, malformed, and empty requests never consume credits. ================================================================================ 9. BILLING ================================================================================ GET /v1/fields Free POST /v1/people/count Free POST /v1/people/search reveal:true (default) 1 credit per record RETURNED POST /v1/people/search reveal:false Free — qualifying fields + contact counts, contacts gated GET /v1/views/{name} Same as search POST /v1/email-to-phone 1 credit per HIT (miss free) POST /v1/phone-to-email 1 credit per HIT (miss free) Any 4xx / 5xx Free Credits are prepaid per key and decremented atomically; a request that would overrun the balance returns 402 and delivers nothing. Reveal model (recommended): count free -> search reveal:false free (see who matches and how many contacts each record has; qualify on demographics/geo) -> search reveal:true charges only for the records whose contacts you pull. The free qualifying step removes the risk that deters large pulls, which in practice RAISES total spend versus an all-or-nothing per-record charge. ================================================================================ 10. RATE LIMITS ================================================================================ Per-key, per-minute, set on the key at issue time. count and fields carry a higher ceiling than search. Exceeding returns 429 with a Retry-After header. Batch large pulls with limit=1000 + cursor paging rather than many small pages. ================================================================================ 11. VERSIONING & STABILITY ================================================================================ - The contract lives under /v1. Additive changes (new fields, operators, optional params) ship without a version bump — read GET /v1/fields rather than hard-coding the field list. - Breaking changes get a new path (/v2); /v1 keeps running. - request_id is on every response; quote it in support tickets. ================================================================================ MCP SERVER ================================================================================ The same endpoints ship as an MCP server so agents can query people data conversationally, no glue code. people_fields -> GET /v1/fields Discover filterable fields. people_count -> POST /v1/people/count Size an audience. people_search -> POST /v1/people/search Return matching records. email_to_phone -> POST /v1/email-to-phone Reverse an email. phone_to_email -> POST /v1/phone-to-email Reverse a phone. Configuration is a base URL + an API key. Tool input schemas mirror the REST filter grammar exactly, so an agent that can read people_fields can compose any people_count / people_search call. Distributed as a stdio server (local Claude Desktop / CLI: npx -y @openappend/mcp) and a remote SSE endpoint (https://api.openappend.com/mcp) gated by the same bearer key. Claude Desktop config (claude_desktop_config.json): { "mcpServers": { "openappend": { "command": "npx", "args": ["-y","@openappend/mcp"], "env": { "OPENAPPEND_API_KEY": "oa_live_…", "OPENAPPEND_BASE_URL": "https://api.openappend.com/v1" } } } } CLI: claude mcp add openappend --env OPENAPPEND_API_KEY=oa_live_… -- npx -y @openappend/mcp ================================================================================ APPENDIX — END-TO-END EXAMPLE ================================================================================ Build a Chicago-metro audience of callable, home-owning mobiles, size it, then pull the first page. BASE=https://api.openappend.com/v1 KEY="Authorization: Bearer oa_live_…" # 1. What can I filter on? curl -s $BASE/fields -H "$KEY" | jq '.categories[].id' # 2. How many match? curl -s $BASE/people/count -H "$KEY" -H "Content-Type: application/json" -d '{ "filters": { "state":"IL","city":"Chicago","age":{"between":[30,55]}, "home_owner":true,"line_type":"mobile","dnc":false } }' | jq '.data.count' # -> 128450 # 3. Pull the first 1000. curl -s $BASE/people/search -H "$KEY" -H "Content-Type: application/json" -d '{ "filters": { "state":"IL","city":"Chicago","age":{"between":[30,55]}, "home_owner":true,"line_type":"mobile","dnc":false }, "fields": ["first_name","last_name","address","phones"], "limit": 1000 }' \ | jq '.data | length, .page.next_cursor' ================================================================================ NOTE ================================================================================ As of August 2026, openappend is in private beta. This document describes the live API contract; access is invite-based until public launch.