Docs
How the pieces fit
One page, for now. It covers the three things people ask an engineer about: how county data gets in, how a field-service system reports a visit, and what a brokerage can change about the look. Everything here is read from the code, and where the code is a placeholder it says so.
County adapters
Every county integration implements one interface, ICountyAdapter. A registry resolves an adapter from whatever county string sits on the property row, after normalizing it, and returns null for a county we do not cover. Every consumer, the portal and the assistant’s home context alike, must tolerate that null and render “not on file” rather than guess.
// src/lib/adapters/types.ts (abridged)
export interface ICountyAdapter {
readonly countyName: string;
readonly state: string;
fetchPropertyByParcelId(parcelId: string): Promise<CountyPropertyData>;
fetchTaxAndHomesteadData(parcelId: string): Promise<CountyTaxData>;
getCountyAlerts(property: CountyPropertyData): Promise<CountyAlert[]>;
// Optional. Consumers feature-detect before calling.
getUtilities?(location?: UtilityLocation): UtilityService[];
getStormResources?(): StormResources;
getInspections?(parcelId: string): Promise<CountyInspection[]>;
}// src/lib/adapters/index.ts
import { getCountyAdapter } from "@/lib/adapters";
// "St. Johns", "st johns", "Saint Johns County, FL" all resolve the same way.
const adapter = getCountyAdapter(property.county); // ICountyAdapter | null
if (adapter?.getUtilities) {
const providers = adapter.getUtilities({ zip: "32080", city: "St. Augustine" });
}What is real and what is simulated
There is one adapter today, St. Johns County, Florida. The county Property Appraiser has no public parcel API, so the adapter’s parcel, tax and permit lookups are a deterministic simulation: every figure derives from a hash of the parcel id, so a parcel returns the same numbers on every request. That simulation is for demo surfaces only.
A live home is built by countyPropertyFromRow from the property row plus its enrichment payload: ATTOM first, then the Florida statewide cadastral or RentCast for assessed value and last sale. PermitStack supplies real permit history where it covers the jurisdiction and says when it does not (St. Johns County was not covered as of August 2026), so an empty permit list renders as unknown rather than as none. The adapter still supplies county alerts, the utilities directory and storm resources; the utilities and storm data are curated by hand and were checked against provider territories in August 2026.
Two fields are deliberately withheld on live rows. Owner of record is never surfaced. Homestead status is unknown, so every homestead message is phrased conditionally and never as an assertion.
Adding a county means implementing the interface in a sibling file and registering its name aliases. The interface and the St. Johns adapter are treated as a contract; consumers are written against the shape, not the county.
Service-visit API
Field-service platforms (ServiceTitan, Housecall Pro, Jobber, or anything that can POST JSON) report completed jobs to one endpoint. The visit lands on the matched homeowner’s portal as reported. The homeowner confirms or dismisses it there. The endpoint never changes a home’s records directly, and a vendor can only report against homes in the brokerages that list that vendor.
Endpoint and authentication
POST /api/integrations/service-visit with a JSON body of at most 32 KB. Send the key as Authorization: Bearer <key> or x-api-key: <key>. Keys are issued per vendor; we store a SHA-256 hash and the first twelve characters as a lookup prefix, compare in constant time, and never log or echo the plaintext.
Keys are minted in the vendor workspace, which is switched off during the invite-only pilot, so no vendor holds a key today. If you run a field-service platform and want to test against a real account, write to support@kepthaven.com.
Request body
| Field | Type | Required | Rules |
|---|---|---|---|
address | string | yes | 5 to 240 characters. The full street line as your system has it. City, state and ZIP are ignored for matching. |
serviceType | string | yes | 2 to 60 characters. What kind of work, for example HVAC tune-up. |
description | string | no | Up to 500 characters. Work performed, tech notes. This text reaches the homeowner and their assistant as untrusted data. |
serviceDate | string | yes | YYYY-MM-DD. Must be a real calendar date and at most one day in the future. |
invoiceTotal | number | no | Zero or more, finite. Invoice total in USD. |
externalRef | string | no | Up to 120 characters. Your job or invoice id, echoed on the visit record. |
curl -X POST https://www.kepthaven.com/api/integrations/service-visit \
-H "Authorization: Bearer lvk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"address": "212 Ocean Trace Rd, St. Augustine, FL 32084",
"serviceType": "HVAC tune-up",
"description": "Annual maintenance. Replaced 16x25 filter, cleared condensate line.",
"serviceDate": "2026-09-03",
"invoiceTotal": 189,
"externalRef": "INV-48213"
}'Address matching
Only the street line matters: everything before the first comma, normalized for case, punctuation and common suffix abbreviations (Rd and Road, St and Street, and so on). Candidates are narrowed in the database to the vendor’s brokerages and the leading house number, and released homes are excluded. Zero matches is a 404; more than one is a 409, and nothing is recorded in either case.
Responses
| Status | error | Meaning |
|---|---|---|
| 201 | Visit recorded with status reported, pending the homeowner's confirmation. matched says whether intake pre-linked an upkeep item or maintenance task. | |
| 400 | invalid_json, invalid_body | Malformed JSON, or a field failed validation. detail names the field. |
| 401 | missing_api_key, invalid_api_key | No key presented, or the key does not match any issued key. |
| 404 | address_not_found | The street line matched no home in the brokerages that list your company. Nothing recorded. |
| 409 | address_ambiguous | The street line matched more than one home. Nothing recorded; send a more specific line, such as a unit number. matches carries the count. |
| 413 | request_too_large | Body over 32 KB. |
| 429 | rate_limited | Over the limit. Retry-After is 600 seconds for the per-IP limit and 3600 for the per-vendor limit. |
| 500 | server_error | Something failed on our side. Retry with backoff; the attempt is logged. |
| 503 | not_configured | A demo deployment without the live backend. Production never returns this. |
HTTP/1.1 201 Created
{
"ok": true,
"visitId": "5f1c2f0a-8d7e-4a4b-9c1e-2b6d3f7a9e10",
"matched": { "upkeepItem": true, "maintenanceTask": false },
"status": "reported"
}HTTP/1.1 400 Bad Request
{ "ok": false, "error": "invalid_body", "detail": "serviceDate: serviceDate must be formatted YYYY-MM-DD" }
HTTP/1.1 401 Unauthorized
{ "ok": false, "error": "missing_api_key", "hint": "Send the key as `Authorization: Bearer <key>` or `x-api-key: <key>`." }
HTTP/1.1 404 Not Found
{ "ok": false, "error": "address_not_found", "hint": "No property matched that street address. Send the full street line as it appears on the homeowner's account ..." }
HTTP/1.1 409 Conflict
{ "ok": false, "error": "address_ambiguous", "matches": 2 }
HTTP/1.1 429 Too Many Requests
Retry-After: 3600
{ "ok": false, "error": "rate_limited", "hint": "Limit is 60 integration requests per vendor per hour." }Rate limits
- Before authentication: 60 requests per IP address per 10 minutes, so a key-guessing spray is throttled before it reaches the key lookup.
- After authentication: 60 attempts per vendor per hour, counted across every key and every outcome. A validation error or an unmatched address counts the same as a created visit, so address probing is throttled exactly like success.
- Over either limit, the response is
429with aRetry-Afterheader.
White-label fields
A brokerage admin controls two groups of settings. Branding Studio sets the identity every portal, claim page and email inherits. The landing page editor shapes the brokerage’s public front door, with a live preview that is the real page. The page skeleton never changes: hero, three value cards, how it works, closing call to action. Fonts and layouts are curated lists, never free input.
| Field | Where | Rules |
|---|---|---|
| Workspace name | Branding Studio | Shown in the top bar and everywhere the site names itself. |
| Workspace URL | Branding Studio | The slug in kepthaven.com/b/<slug>. Changing it moves every page; links shared before stop working. |
| Logo URL | Branding Studio | An https URL. The studio removes a flat background automatically and stores the cleaned mark. |
| Primary color, secondary color | Branding Studio | Hex values. They become the tenant palette on every portal, claim page and email; the landing page cannot override them. |
| Agent card | Branding Studio | Name, title, phone, email, CMA request link and headshot upload for the agent shown on portals. |
| Layout | Landing page | One of classic, editorial, split, minimal. Only the hero composition changes; the page skeleton is fixed. |
| Font pairing | Landing page | One of modern (Inter), editorial (Playfair Display and Inter), warm (Lora and Source Sans), coastal (DM Serif Display and DM Sans), bold (Montserrat), classic (Merriweather and Inter). |
| Tone | Landing page | auto, light or dark text over the hero. auto follows the photo scrim or the brand color's luminance. |
| Background photo | Landing page | An https URL, with a dark or light overlay at a strength from 0 to 0.85. Minimal needs no photo. |
| Copy | Landing page | Badge (40 characters), headline (90), subheadline (220), call-to-action label (32), three cards (title 48, copy 180), closing headline (90). Required fields fall back to the default when emptied. |
| Agent card toggle | Landing page | Whether the agent's card appears on the page. |
The stored landing configuration
The brokerage’s configuration is validated field by field on read, and a bad or missing value falls back to the default, so a single stale entry can never blank the page.
{
"layout": "split",
"font": "coastal",
"tone": "auto",
"backgroundImageUrl": "https://example.com/hero.jpg",
"overlay": "dark",
"overlayStrength": 0.45,
"badge": "Your agent, for life",
"headline": "Everything about your home, in one place.",
"subheadline": "Every home we close comes with a private portal.",
"ctaLabel": "See your home's portal",
"cards": [
{ "title": "Your documents, found in seconds", "copy": "..." },
{ "title": "Deadlines you never have to remember", "copy": "..." },
{ "title": "Pros your agent already trusts", "copy": "..." }
],
"closingHeadline": "Ready to see your home's portal?",
"showAgentCard": true
}What an agent may change
An agent’s page layers a small override on the brokerage configuration. They can change what they say and where their portrait sits, not how the brokerage looks: layout, fonts, photo and colors all inherit.
// What an agent may change on their own page. Everything else inherits.
{
"headline": "Hi, I'm your agent for life.",
"subheadline": "...",
"ctaLabel": "...",
"intro": "Up to 320 characters about you.",
"showAgentCard": true,
"portraitPlacement": "hero" | "below"
}For how these settings reach a homeowner and what they can and cannot see, read who can see a homeowner’s vault.