Skip to content

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-Key header, 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 propertyfloorplansunits 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=:

  1. Backing the ops dashboard's email view (read resources).
  2. Providing an SMTP submission relay the outreach daemon uses because its hosting provider blocks outbound SMTP ports — see ../operations/deployment.md for 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.

  • Client API Reference — the public feed this API mirrors internally (/properties) and fronts for refresh admission (/refresh).
  • Warehouse schemafunnel_daily, coverage_daily, outreach_daily, communications, refresh_queue.
  • Glossarylead_tier, metro vocabularies, and other terms used throughout.
  • Deployment — Vercel deploy details and the outreach daemon's SMTP-block workaround.
  • Environment variablesDATABASE_URL, INTERNAL_API_KEY, MAIL_ACCOUNTS.