Documentation
Concepts
Products, offers, stores, claims, intents and evidence — the model behind the API.
The FlowFinds API has a small vocabulary and a strict one. Eight nouns carry almost every endpoint, and most surprises in the API come from a relationship between two of them rather than from any single field. This page teaches the model. Read it before the API reference, not after.
One idea underlies the rest: the system refuses rather than guesses. If a request would require inventing a fact — a price with no observed listing behind it, a copy change with no dated evidence justifying it — the endpoint declines and names what is missing. Almost every “error” you will meet is that rule speaking.
The model at a glance
Product
A product is a catalogue entity, not something you create. It carries an id, a name, photography, and — decisively — a list of evidence rows. Derived figures the API serves alongside it are computed, never stored twice: score, evidence_age_days, freshness.
A product served to you also states its own availability rather than making you infer it. servable says whether anything is left to show, yours says whether this one is already yours, and preview_available with preview_url exist because /preview/<id> returns 404 without a live offer. The URL is served so that no client hand-builds a path that will 404.
Claim and exclusivity
This is the concept that most changes how you write client code. A product is claimed by exactly one founder. There is no second claim, no waitlist, and no un-claiming.
Three states, and they are genuinely different facts:
| State | Meaning | Reason code you will see |
|---|---|---|
| Held | Temporarily resolved to your session while you decide. A hold expires. A hold is not ownership, and endpoints that require ownership say so. | held_by_another_founder |
| Claimed | Permanent and exclusive. No other session may be shown or sold it. | claimed_by_another_founder |
| Passed | You declined it. Passing is final and is never re-offered to you. | passed_by_you |
Two rules follow. First, you may only claim a candidate you were shown — an id invented by the client is refused with not_shown, which is what makes the reveal mean anything. Second, a 409 on a claim is not a retryable error. Another founder won; retrying the same id will lose forever. Move to another candidate.
Exhaustion is likewise split into distinct states rather than one generic emptiness, because the exits differ: all_held (other founders hold everything right now), passed_out (you declined everything we could show you), catalog_exhausted (there is nothing left at all), and claim_limit_reached (your own allowance — one action lifts it). Never collapse these four in your UI; only one of them has an exit.
Offer
An offer is the price layer over a product: what you sell it for, what margin that implies, and the observed third-party listings that price undercuts. An offer either exists — status: "offer" — or it does not, and when it does not the response carries the reason instead of a plausible number. A product with no offer has no preview page, because a preview with an invented price is worse than no preview.
Store
The store is generated, not authored. Building one is asynchronous, and the job reports itself through a sequence of states you should render rather than hide: researching, generating, checking_domains, verifying, then ready or failed.
Readiness is verified rather than asserted: a manifest must exist on disk and its browsable path must resolve before the store reports ready. Publishing is a real act, not a label — it re-crawls the store and confirms what a visitor will actually receive, regenerating first if the manifest is stale. A store that has not been built refuses publication with not_started.
Intent, with because and source_row
An intent is how a change is requested. It is a proposal, never an application: applied_at stays null until someone accepts it in a separate call.
The shape is the argument:
{
"status": "proposed",
"intent_change": {
"surface": "store.section_order",
"requested_by": "founder",
"requested": "make it feel more urgent",
"proposed": {
"diff": "lead the page with the urgency section",
"lead": "urgency",
"preview": "…the first sentence of the cited claim…"
},
"because": "a dated urgency row exists and it is what a buyer acts on",
"source_row": ["https://example.com/the-actual-source"],
"dated": "2026-08-14",
"alternatives_considered": [
{ "option": "lead with specifications",
"rejected_because": "specs answer a buyer already deciding, not one deciding whether to care" }
],
"reversible": true,
"applied_at": null
}
}because is the prose reason. source_row is the URL of the dated evidence row that justifies it, and dated is that row’s date. They are not decoration: an intent that no evidence row supports is refused with no_row_justifies_this rather than executed anyway. proposed.lead is the machine-readable half of the same decision the prose states, so an accepting client never has to parse a sentence.
Intents also refuse presentation instructions. A request containing a pixel value or a hex colour is answered with not_an_intent: “tell us what you want it to do, not what size to make it”. A dimension cannot be justified by an evidence row, which is the whole test.
Accepting a proposal has its own refusals worth handling: no_proposal_echoed (send the proposal back to accept it), stale_proposal (the evidence moved since it was shown), nothing_to_apply, and irreversible_without_confirmation.
Evidence
Evidence is a dated, sourced row attached to a product. Each row has a URL, a date, a claim and a leg — the persuasion function it can serve, such as urgency or scarcity. Evidence is the currency the whole system spends: it drives evidence_age_days and freshness, it is what an intent must cite, and an ad may not assert a present-tense angle on a row older than the permitted age.
Reasons and controls
Two registries are served over HTTP rather than only written down. GET /api/reasons returns every refusal code the server can emit with its explanation — asserted in both directions, so a code the server can emit but has not declared is a build failure rather than something a user discovers. GET /api/controls returns, per endpoint, its consequence, whether it is reversible, and a note on what it does not do. A UI that renders a control should read its consequence from here rather than restating it.
Verdict
The dashboard does not show a score. It shows a verdict, which is one of two states: quiet — nothing needs you — or attention, which arrives with the specific items that earned it. A third state, not_servable, means your product has left the served set.
The dashboard restates the other surfaces through the same resolvers they use; it never recomputes a figure. That is why its response carries a source field naming the resolver behind each number: two screens that compute one fact independently are two screens that will eventually disagree.
Referral
A referral link is a durable, account-owned code — derived by hashing, never sequential, so nobody can enumerate another founder’s code. Signing in on a second device does not change your link; every historic device code is kept alive as an alias so an old link never orphans the people who used it.
A referral counts when it is qualified, which is neither “invited” nor “clicked”: the friend really joined and reached the dashboard, an observed milestone. Qualified referrals multiply your usage limits on every tier including Free — the numbers are on Rate limits and quotas.
Session and account, in one sentence each
A session is the ff_session cookie: an anonymous journey that can claim, build and hold things. An account is an email address; binding one adopts everything the session already did. Signing out unbinds the browser and touches neither the account nor its claims. See Authentication.
Next: Authentication · API reference · Glossary