Internal API Reference¶
Internal only
This API is not for external consumers. It backs the ops dashboard
(foundationaldata-dashboard) and the outreach daemon. A leaked key here can read live
mail and admit refresh-queue work — treat INTERNAL_API_KEY with the same care as a
database credential, not like the public client-api key.
Deployed as its own Vercel project (internal-api/), with its own DB role
(DATABASE_URL, read-write) separate from the Client API's DATABASE_URL_RO, so a leaked
credential on one surface can never reach the other.
Authentication¶
Every route requires X-Internal-Key, accepted via either:
- the
X-Internal-Keyheader, or - an
?key=query parameter.
GET /health HTTP/1.1
Host: internal-api.foundationaldata.example
X-Internal-Key: xxxxxxxxxxxxxxxx
GET /health?key=xxxxxxxxxxxxxxxx HTTP/1.1
Host: internal-api.foundationaldata.example
Query-param auth is deliberate, and internal-only
Accepting the key as a query parameter is a deliberate internal-only convenience (e.g. for pasting a URL directly into a browser while debugging). The public Client API never accepts credentials this way — header-only there.
On a missing or incorrect key, every route returns:
{ "error": "missing or bad X-Internal-Key" }
with HTTP 401.
Fails closed
If the INTERNAL_API_KEY environment variable itself is unset, auth fails closed —
every request is rejected — rather than failing open and admitting unauthenticated
traffic.
GET /health¶
Purpose: dataset/crosswalk sanity counts.
Params: none.
Response 200¶
{
"ok": true,
"relive_properties": 12345,
"our_buildings": { "mia": 6789, "chi": 111 },
"crosswalk_matches": 4321,
"data_ingested_at": "2026-08-01T12:00:00.000Z"
}
Errors¶
| Status | When |
|---|---|
401 |
Missing or invalid X-Internal-Key. |
GET /summary¶
Purpose: metro-level "ours vs. partner's" coverage summary.
Params¶
| Param | Type | Required | Notes |
|---|---|---|---|
metro |
string | no | Default "". |
Response 200 — uncovered metro¶
If metro isn't a currently-covered metro (currently only mia / Miami), the route
returns early:
{ "metro": "chi", "covered": false, "relive_buildings": 42 }
Response 200 — covered metro¶
{
"metro": "mia", "covered": true, "our_metro": "mia",
"relive_buildings": 500, "our_buildings": 800,
"our_tiers": { "candidate": 300, "phql": 200, "hql": 100 },
"our_commission_known": 150, "our_commission_pct": 18.8,
"our_email_pct": 42.5, "our_phone_pct": 55.0, "our_website_pct": 60.2,
"our_with_rent_data": 400,
"overlap_matched": 350, "our_only": 450, "relive_matched": 360
}
Errors¶
| Status | When |
|---|---|
401 |
Missing or invalid X-Internal-Key. |
POST /rows¶
Purpose: batch-match a list of building names/addresses against the partner's inventory, for badge rendering in the dashboard.
POST only
Other HTTP methods on this route return 404.
Body¶
{ "rows": [ { "name": "string", "address": "string" } ] }
Only the first 200 entries in rows are used; extras are silently ignored.
Response 200¶
{
"metro": "mia",
"rows": [
{
"name": "Some Building", "relive_id": "abc123", "status": "commission",
"join_key": "jk_1", "lead_tier": "hql", "locator_commission": "1 month",
"commission_basis": "...", "locator_confidence": "high"
},
{ "name": "Unknown Bldg", "relive_id": null, "status": "unknown" }
],
"summary": { "commission": 1, "unknown": 1 }
}
status is one of:
| Value | Meaning |
|---|---|
unknown |
No or ambiguous match against the partner's inventory. |
missing |
Matched to the partner but no crosswalk to our data. |
known |
Joined, no commission on file. |
commission |
Joined, commission known. |
Errors¶
| Status | When |
|---|---|
401 |
Missing or invalid X-Internal-Key. |
404 |
Non-POST method. |
GET /dashboard¶
Purpose: single dispatcher (via ?resource=) backing the ops dashboard's metrics views,
reading the funnel_daily / coverage_daily / outreach_daily / communications rollup
tables. See ../data-model/warehouse-schema.md for
those table definitions.
Params¶
| Param | Required | Notes |
|---|---|---|
resource |
yes | Unrecognized or missing value → 400. |
resource values¶
resource |
Params | Returns |
|---|---|---|
scopes |
— | Array of { scope_type, scope_value } distinct rollup scopes. |
funnel |
scope_type, scope_value, from, to (date strings) |
Array of daily funnel rows (contacted/replied/tier counts/avg commission/rent coverage). |
coverage |
scope_type, scope_value, table |
Latest coverage snapshot for that table: { snapshot_date, rows: [{ table_name, column_name, total_rows, non_empty_rows, pct_filled }] }. |
method-success |
— | Per-channel send/reply-rate array, computed from communications. |
outreach-daily |
from, to |
Array of daily per-channel outreach rollup rows. |
Errors¶
| Status | When |
|---|---|
401 |
Missing or invalid X-Internal-Key. |
400 |
Missing or unrecognized resource. |
GET /compare¶
Purpose: head-to-head comparison of the partner's inventory vs. ours for a metro, with search/filter, backing the dashboard's compare view.
Params¶
| Param | Type | Required | Notes |
|---|---|---|---|
metro |
string | no | |
q |
string | no | Free-text search. |
beds |
int | no | |
price_min |
float | no | |
price_max |
float | no |
Response 200 — uncovered metro¶
{ "metro": "chi", "q": "", "relive_count": 42, "covered": false }
Response 200 — covered metro¶
Includes, in addition to the uncovered-metro fields:
| Field | Meaning |
|---|---|
our_count |
Our building count for the metro (after q/beds/price filters). |
our_tiers |
Fill-rate percentages by lead_tier. |
relive_matched_by_us / relive_matched_pct |
How much of the partner's inventory we match. |
our_only_count |
Count of buildings we have that the partner doesn't. |
our_only_sample |
Capped sample (up to 50 rows) of those buildings. |
overlap |
Block comparing commission-knowledge and contact-info agreement across matched buildings. |
Errors¶
| Status | When |
|---|---|
401 |
Missing or invalid X-Internal-Key. |
GET /properties, GET /properties/{id}¶
Purpose: internal equivalent of the public Client API bulk-sync feed (same nested
property → floorplans → units shape — see client-api.md), for the
team's own internal frontend. Includes per-row source-attribution fields the public
contract deliberately omits.
GET only
Other HTTP methods return 405.
Params¶
Supports the same include / limit / updated_since / cursor query params as the
public API — see client-api.md for full semantics —
with matching 400 validation errors for each.
No caching
Unlike the public API, these routes do not return an ETag and do not support
If-None-Match — the internal frontend always fetches fresh.
Errors¶
| Status | When |
|---|---|
401 |
Missing or invalid X-Internal-Key. |
405 |
Non-GET method. |
400 |
Same validation errors as the public API's include/cursor handling. |
404 |
Unknown property id, or an unrecognized route. |
POST /refresh¶
Purpose: the admission endpoint behind the public
POST /v1/properties/{id}/refresh action —
resolves ids, applies guardrails, and writes to refresh_queue. The actual re-scrape
happens later, out of band, via the refresh-drain worker — see
../architecture/data-pipeline.md.
Body¶
{ "ids": ["..."], "reason": "optional, truncated to 200 chars" }
An id with no : is treated as a partner property id. An id of the form
"<metro>:<join_key>" addresses one of our own buildings directly.
Guardrails¶
| Guard | Limit |
|---|---|
| Ids per request | ≤100 (deduplicated first) |
| Per-property cooldown | 6 hours since last completed refresh |
| Rate limit | ≤20 queue admissions/hour, keyed by a hash of the caller's API key |
| Metro coverage | Only currently-covered metros (Miami, mia) are admitted — others resolve to unknown_id |
Response 200¶
{
"batch_id": "rfb_1754300000_a1b2c3",
"queued": 3, "already_fresh": 1, "unknown": 1,
"results": [
{ "id": "relive_prop_1", "state": "queued", "metro": "mia", "join_key": "jk_1" },
{
"id": "relive_prop_2", "state": "already_fresh", "metro": "mia",
"join_key": "jk_2", "last_refreshed_at": "2026-08-04T02:00:00.000Z"
},
{ "id": "chi:some_join_key", "state": "unknown_id" }
]
}
Errors¶
| Status | When |
|---|---|
401 |
Missing or invalid X-Internal-Key. |
400 |
Bad ids shape, or too many ids. |
429 |
Rate limit exceeded. Retry-After: 3600 header is set. |
500 |
Unexpected failure. |
GET /refresh/{batch_id}¶
Purpose: poll the state of a previously-submitted refresh batch. refresh_queue also
serves as permanent refresh history, so a finished batch stays queryable indefinitely.
Params: none beyond the path segment.
Response 200¶
{
"batch_id": "rfb_1754300000_a1b2c3", "state": "done",
"requested_at": "2026-08-04T01:00:00.000Z",
"items": [
{
"id": "relive_prop_1", "state": "done", "metro": "mia", "join_key": "jk_1",
"source": "scrape_v2", "last_refreshed_at": "2026-08-04T02:00:00.000Z",
"rent_min": 1500, "rent_max": 2400, "rent_avg": 1900,
"floorplan_count": 5, "unit_count": 20
},
{
"id": "relive_prop_2", "state": "error", "metro": "mia", "join_key": "jk_2",
"error": "scrape timeout", "attempts": 3
}
]
}
Batch-level state is derived from item states: running if any item is running, else
queued if any item is pending, else done.
Errors¶
| Status | When |
|---|---|
401 |
Missing or invalid X-Internal-Key. |
404 |
The batch id doesn't exist. |
GET/POST /mail¶
Purpose: two distinct capabilities dispatched via ?resource=:
- Backing the ops dashboard's email view (read resources).
- Providing an SMTP submission relay the outreach daemon uses because its hosting
provider blocks outbound SMTP ports — see
../operations/deployment.mdfor that context (the SMTP-block section).
Credentials come from a MAIL_ACCOUNTS environment variable (JSON array of mailbox
configs) — see ../operations/environment-variables.md.
They are never exposed to callers.
Live mailbox access
These routes read and send live email on configured mailboxes. Treat this endpoint with the same caution as the mailbox credentials themselves.
Read resources (GET)¶
resource |
Params | Purpose |
|---|---|---|
accounts |
— | List configured mailboxes (label/email only). |
folders |
account |
List IMAP folders with unread/total counts (special folders only, for speed). |
messages |
account, folder (default INBOX), page, size (max 100) |
Paginated message list, newest first. |
message |
account, folder, uid |
Full message body (HTML sanitized, quoted-reply text stripped); marks the message read as a side effect. |
thread |
account, folder, uid |
Reconstructs the full conversation thread around one message (by Message-ID headers, falling back to subject+correspondent matching for older mail lacking thread headers); marks the requested message read. |
Write resources (POST)¶
resource |
Purpose |
|---|---|
send |
Compose and send a new message from the dashboard; live SMTP send, best-effort copy to the account's IMAP Sent folder. Body: { account, to, cc?, bcc?, subject, text, inReplyTo?, references? }. Validates recipient count (≤20), address format, subject/body length. |
relay |
The SMTP-block workaround: accepts a fully-formed, base64-encoded raw MIME message (≤2MB decoded) from the outreach daemon and submits it verbatim via SMTP — no re-composition, no IMAP Sent copy (the daemon already does its own IMAP append over a port that isn't blocked). Body: { account, to, raw }. |
Errors¶
Non-standard error status codes
Unlike the other routes on this API, read-resource errors (e.g.
{"error": "unknown account"}) come back with HTTP 200, not a 4xx — the caller
must inspect the body. Only unrecognized resource values and send/relay
validation failures return 400.
| Status | When |
|---|---|
401 |
Missing or invalid X-Internal-Key. |
400 |
Unrecognized resource value, or send/relay body validation failure. |
200 (with {"error": ...} body) |
A read resource failed (e.g. unknown account/folder/uid). |
Env vars this API needs¶
See ../operations/environment-variables.md for
the full table. In short: unlike the Client API's DATABASE_URL_RO, this API needs
DATABASE_URL (read-write — it writes communications rows via /mail?resource=relay
and admits refresh_queue work via /refresh) plus MAIL_ACCOUNTS for the /mail
routes, and INTERNAL_API_KEY for auth on every route above.
Related documentation¶
- Client API Reference — the public feed this API mirrors internally
(
/properties) and fronts for refresh admission (/refresh). - Warehouse schema —
funnel_daily,coverage_daily,outreach_daily,communications,refresh_queue. - Glossary —
lead_tier, metro vocabularies, and other terms used throughout. - Deployment — Vercel deploy details and the outreach daemon's SMTP-block workaround.
- Environment variables —
DATABASE_URL,INTERNAL_API_KEY,MAIL_ACCOUNTS.