v1 recipes LIVE
Composite signal queries as deterministic, billable plans. A recipe names the geography, the signals, and the per-signal params; the compiler resolves it to an execution plan before any data is touched — and the estimate binds the charge. Everything below is verified against the deployed functions code (functions/lib/v1-recipes.js, functions/v1/recipes/*). Planned surfaces stay DRAFT-badged.
calls = parcels × signals, deterministic, computed by compileRecipe before a single adapter runs. The estimate binds the charge: calls_estimated must equal calls_consumed for the same recipe version + parcel set, or the difference is credited — only completed evaluations consume quota, so an estimate above actual is automatically credited. Save and estimate are unbilled; only runs bill, and only for completed evaluations. Every cost figure carries "modeled": true — the $0.003/call price is the plan's pricing math (M), not a price anyone has been charged. No real charges exist today.the three endpoints
| endpoint | what it does | billing |
|---|---|---|
POST /v1/recipes | save a recipe — the unit of reuse. Body must be { recipe, name? }. | unbilled |
POST /v1/recipes/estimate | pre-execution cost estimate. Body must be { recipe } or { recipe_id }. Idempotency-Key not required. | unbilled |
POST /v1/recipes/{id}/run | execute a saved recipe. Idempotency-Key header required (400 idempotency_key_required without it). | billed N×P (signals × parcels), completed evaluations only |
Save returns 201 with recipe_id (r_ + 24 hex chars), recipe_version (today: 1), name (your name, sliced to 120 chars, or the recipe's own name), signals, parcels_in_scope, and stored_via (d1 when the binding is present, memory otherwise).
the recipe object — exact validated shape
{
"geography": {
"type": "parcel_list", // county_list | radius | parcel_list | bounding_box
"parcel_keys": [ // required for parcel_list
{ "state_fips": "51", "county_fips": "087", "apn_normalized": "E-123-4" }
]
},
"signals": [ // non-empty array; each entry needs a signal name
{ "signal": "absentee_high_equity", "params": { } }
],
"logic": {
"exclusions": [ // evaluated AND billed like any signal
{ "signal": "recent_buyer", "params": { } }
]
}
}
- parcel keys are exact:
state_fipsmust be a 2-digit FIPS string,county_fipsa 3-digit FIPS string,apn_normalizeda non-empty string of 64 chars or fewer (trimmed). No address or lat/lng normalization — a parcel key is the only addressable unit. - signal resolution: an unknown name returns
400 unknown_signal; a name in the 228-signal catalog with no working v1 adapter returns404 signal_not_built— the 228 count is not a working-API count. - exclusions are evaluated and billed like any other signal (
nSignalscounts them) — the compiler adds them to the resolved signal list before computingcalls. - per-signal params merge from
signals[].paramsandlogic.exclusions[].params; each adapter receives exactly its own params at run time.
compile-time errors
| code | status | when |
|---|---|---|
bad_params | 400 | recipe not an object; recipe.geography missing or not an object; geography type not one of the four; signals missing, not an array, or empty; a signal entry without a string signal name; logic.exclusions present but not an array |
unknown_signal | 400 | a signal name not in the 228-signal catalog |
signal_not_built | 404 | a catalog signal with no working v1 adapter yet |
invalid_parcel | 400 | a parcel_key failing the FIPS/APN shape above |
geography: exact vs streaming LIVE
MVP geography support is honest about what resolves:
| geography type | estimate | run |
|---|---|---|
parcel_list | exact: parcels_in_scope is the count, calls_estimated is parcels × signals, cost_estimated_usd is computed at the $0.003/call modeled price | executes |
county_list / radius / bounding_box | streaming: parcels_in_scope: "streaming", calls_estimated: null, cost_estimated_usd: null — no parcel-inventory database exists, and inventing a parcel count would be inventing coverage. The parcel set resolves at run time via paged execution (1,000 parcels/page); per-page billing is reported as a running total. The charge is never open-ended. | 400 bad_params with a geography_not_supported: message — run executes over parcel_list geography only. Re-save the recipe with parcel_list. |
The estimate carries its own hint: "note": "Streaming geography: pass parcel_list geography for an exact pre-run figure." Streaming run execution is planned, not shipped (see DRAFT below).
the estimate binds the charge
Every estimate response carries: api_version (v1), estimate_id (est_ + 24 hex chars), recipe_hash (sha256 over the recursively key-sorted recipe JSON — stable regardless of key insertion order), recipe_version, parcels_in_scope, signals, calls_estimated, cost_estimated_usd, price_per_call_usd (0.003, modeled), plan_coverage, quota, and the idempotency note "Re-running within 24h with the same recipe version costs $0 additional (idempotency)."
- plan coverage:
included_remaining,overage_calls(calls above remaining quota — withoverage_cost_usd: 0because the free tier is a hard stop, never billed over),fits_quota, and the note "Free tier: hard stop at quota, no overage billing." - the large-run gate: runs above 1,000 calls require the
estimate_idfromPOST /v1/recipes/estimate— without it, run returns400 bad_paramswith anestimate_required:message. No blind execution. - estimate validation: run re-hashes the stored recipe and compares — a stale or mismatched estimate fails with
409 bad_paramscarrying anestimate_mismatch:message ("this estimate was computed for a different recipe version"). - the credit rule: when
estimate_idbinds, the billing block carriescredit_calls=max(0, calls_estimated − calls_completed)andcredit_usdat the modeled price. Failed evaluations are never charged, so anything the estimate assumed but execution didn't complete is credited explicitly.
execution: pages, quota, and what bills
- pages: runs page through parcels at 1,000 parcels per page (
PAGE_PARCELS). The response carriespagination: { cursor, next_cursor, parcels_total, parcels_in_page }— passnext_cursorback ascursorfor the next page. The idempotency scope includes the cursor, so each page has its own key scope. - quota fits the whole page: the page's charge must fit inside remaining monthly quota, or the page is refused with
402 quota_exhaustedand nothing is billed — no partial page billing. - completed-only: each parcel's adapter is evaluated; a failed evaluation is itemized in
resultswitherror/error_codeandbilled: false. Onlycompletedevaluations consume quota, and the response carries the note: "N evaluation(s) failed and were not billed; only completed evaluations consume quota." - adapter contract check: an adapter returning
match: truewith a non-positiveevidence_status(missing/not_covered/unresolved) is treated as a failure, not a match. - rights block: every run response carries
rights: { signal_license: "restricted", source_rights_verified: false }. - replays: the same
Idempotency-Keywith the identical payload returns the stored response; the same key with a different payload is rejected with409 idempotency_replay.
planned: streaming run execution DRAFT
county_list, radius, and bounding_box recipes estimate honestly today but cannot run — the parcel-inventory database does not exist, so POST /v1/recipes/{id}/run answers 400 geography_not_supported. When the inventory ships, these geographies execute with the same paged, per-page-billed, estimate-bound contract. The estimate's streaming shape (calls_estimated: null) will be replaced by a real pre-run figure — not final.
planned: bound charges for streaming geography DRAFT
Today the charge can only be bound for parcel_list recipes, because only they have an exact parcel count. Until the inventory ships, streaming-geography charges cannot be pre-bound — the per-page running-total reporting in estimate responses is the contract, and no exact total is printed because none is knowable. Exact binding rules for streaming geographies are not final.
GET /v1/recipes list endpoint, no recipe update endpoint, and no delete endpoint anywhere in the codebase — recipes are saved, estimated, and run, and that is the full surface. Any list/update/delete path mentioned elsewhere would be invented, which is why none are printed here.next: usage & billing · api keys · api reference · docs hub