Documentation
Errors
Every error code the API returns, and how to handle each one.
Errors in this API are named, enumerable and mostly not HTTP errors. Two properties matter more than any individual code, and getting them wrong is the usual cause of a brittle client.
Two rules before the table
1. Branch on reason, not only on status
A great many refusals are returned with HTTP 200. That is deliberate: the request was well-formed, it was understood, and the answer is a fact about your account or the catalogue rather than a protocol failure. “Every product is spoken for right now” is not a 4xx. The same reason code can also carry different HTTP statuses on different endpoints, so reason is the stable key.
{
"status": "refused",
"reason": "claimed_by_another_founder",
"explanation": "Another founder claimed this one first. Your other candidates are still yours to take."
}status is one of refused, error, abstained, or a domain state such as ok, proposed, not_claimed, quiet, attention. Some routes on the proof surface use the shape {"error":"not_found","reason":…} instead; handle both.
2. The vocabulary is served, so generate against it
GET /api/reasons returns the complete registry — every code the server can emit, with its sentence — and it is asserted in both directions: a code the server can emit but has not declared fails the build, and a code declared but unreachable fails it too. So a client can be generated from the live registry rather than from a list a human transcribed.
curl -s https://flowfinds.ai/api/reasons | python3 -c '
import json, sys
d = json.load(sys.stdin)
print(d["count"], "reason codes")
for code, sentence in sorted(d["reasons"].items()):
print(f"{code:38} {sentence}")'The table below is written from that registry, grouped by the surface that raises each code. Where a code is emitted by more than one endpoint with a different HTTP status, both statuses are given.
Every reason code
Account and sign-in
Raised by /api/account, /api/login/request, /login and every account-scoped surface.
| Reason | Status | Cause | What to do |
|---|---|---|---|
account_required | 400 · 409 | This needs an account, which takes one email. | 400: the string cannot be an email address — fix the input. 409: the address already belongs to a verified account, so it is never bound from a form. A sign-in link has been sent; open it. |
invalid_link | 303 to /#login?e=invalid_link | That sign-in link has expired or was already used. | Request a new one. Expired, spent and never-existed are deliberately one answer, so do not try to distinguish them. |
no_account | 401 | A product edit must belong to a signed-in account. | Bind an account with POST /api/account, or sign in. |
no_account_yet | 200 | Claim a product or save your account before this exists. | A state, not a failure. Render the empty surface honestly and offer the action that creates it. |
invalid_email | 400 | The account email is missing or invalid. | Send a syntactically valid address. Shape is checked; deliverability is not claimed. |
Claiming and exclusivity
The exclusivity contract, speaking. Most of these are terminal for the id involved.
| Reason | Status | Cause | What to do |
|---|---|---|---|
not_shown | 409 | You can only claim a candidate you were shown. | Do not synthesise product ids. Claim only from what /api/find or /api/feed returned to this session. |
claimed_by_another_founder | 409 | Another founder claimed it first. | Terminal. Never retry this id — move to another candidate. Your other candidates are unaffected. |
held_by_another_founder | 409 | Another founder is claiming it right now. | A lost race at the moment of insert. Fetch a fresh candidate rather than retrying in a loop. |
passed_by_you | 409 | You let this one go, and passing is final. | Terminal by design. Passing is not reversible. |
all_held | 200 | Every product is spoken for by other founders right now. | Try later. Distinct from the three below — do not collapse them. |
passed_out | 200 | You passed on everything we could show you. | No exit exists for this session's catalogue. Say so plainly. |
catalog_exhausted | 200 | There is nothing left in the catalogue. | Nothing to retry. |
claim_limit_reached | 200 | You are at your allowance; sharing unlocks one more. | This one has an exit. Surface the share action rather than reporting emptiness. |
not_servable | 200 | Your product is out of the served set right now. | Read, do not write. The dashboard reports this rather than pretending the product is gone. |
unknown_product | 400 · 200 | We do not have that product. | The id is not in the catalogue at all. |
no_product | 200 · 400 · 409 | This session has no product yet. | Resolve a product first. Note the status varies by endpoint — branch on reason. |
no_claim | 400 | An outcome is a fact about a product you own; a hold is not ownership. | Claim before recording outcomes. A hold is explicitly not enough. |
not_claimed | 200 | No product is resolved for this session at all. | The first-run state of /api/dashboard. Not an error. |
The store
Generation is asynchronous; publication is a verification, not a label.
| Reason | Status | Cause | What to do |
|---|---|---|---|
not_yet_researched | 200 | The store has not been started. | Start a build. |
not_started | 409 | Your store has not been built yet, so there is nothing to edit. | Build before editing or publishing. |
build_in_progress | 200 | A build is already running for this product. | Poll the job state instead of starting a second build. |
generation_failed | 200 | The store could not be generated. | Ours, not yours. The job reports failed; retry or contact support. |
verify_failed | 500 | We could not re-check your store, so we are not calling it published. | The pages may still be fine — publication merely could not be confirmed. Retry. |
unknown_section | 200 · 400 | This store has no such section to lead with. | Read the section list from the store before ordering it. |
no_section | 400 | An ordering with no section named is not an ordering. | Name the section. |
Intents and guided editing
The evidence rule, enforced. See Concepts for the shape of a proposal.
| Reason | Status | Cause | What to do |
|---|---|---|---|
not_an_intent | 200 | That is a pixel value, not an intent. | Describe the effect, not the execution. A request carrying px/pt/em/rem/% or a hex colour is refused, because a dimension cannot be justified by an evidence row. |
no_row_justifies_this | 200 | No dated evidence would justify that change. | Not a bug. The change would be a guess at your buyer, so it is declined rather than made. |
no_proposal_echoed | 409 | Send back the proposal you are accepting. | Echo the intent_change you were served, unmodified. |
stale_proposal | 409 | The evidence moved since that proposal was shown. | Request a fresh proposal and show the new because and source_row before applying. |
nothing_to_apply | 409 | That proposal changes no surface. | Nothing to do. |
nothing_to_revert | 409 | You have not applied a change to this product. | Nothing to undo. |
irreversible_without_confirmation | 409 | This cannot be undone without explicit confirmation. | Confirm explicitly. Read the reversible flag on the proposal, and GET /api/controls for the endpoint's consequence. |
intents_book_unreadable | 500 | The applied-intent record is unreadable. | Ours. Report it. |
apply_build_raised · revert_build_raised · order_build_raised | 500 | The regeneration after an apply, revert or reordering failed. | Ours. The intent was understood; the rebuild was not completed. |
The AI editors
POST /api/ai/edit and POST /api/growth/edit. These are the metered surfaces.
| Reason | Status | Cause | What to do |
|---|---|---|---|
no_message | 400 | An edit with nothing asked is not an edit. | Send a non-empty message. |
message_too_long | 400 | That instruction is longer than the editor accepts. | Shorten it, or split it into two edits. |
cli_unavailable | 200 · 500 | The editor's AI is not available on this machine, so nothing was changed. | Environmental, and deliberately distinct from a billing misconfiguration. Nothing was spent and nothing was altered. |
cli_failed | 200 · 500 | The editor's AI could not be run, so nothing was changed. | The run was attempted and did not complete. Retry once, then report. |
editor_unavailable | 200 | The editing model refused, and said why. | Show the accompanying explanation to the person; it is the model's own reason. |
unbacked_claim | 200 | Your product record cannot back that wording. | The same evidence rule as intents, applied to copy. Add the record or change the wording. |
unknown_field · unknown_diff · unknown_version · no_diff · too_long | 200 · 400 | The requested edit does not name a field, shape, version or length this store has. | Read the store's current shape before constructing an edit. |
nothing_to_undo · nothing_to_redo · no_history · no_direction | 200 | Version navigation with nothing to navigate, or no direction given. | Say undo or redo, and check history exists first. |
invalid_platform | 400 | That is not a Facebook or TikTok ad. | Only those two platforms are accepted here. |
invalid_editor_response | 200 | The ad editor returned no safe editable fields. | The model's output failed validation, so nothing was applied to the preview. |
Usage and the meter
Declared in the meter itself. See Rate limits for windows, buckets and multipliers.
| Reason | Status | Cause | What to do |
|---|---|---|---|
usage_limit_reached | 429 | You have used this feature's allowance for now. | Back off until resets_at (epoch seconds, in the body — there is no Retry-After header). Other features are unaffected; the response names them. |
weekly_limit_reached | 429 | You have used this feature's weekly allowance. | Do not wait this one out in code. Surface it, with the referral and upgrade paths the body carries. |
unknown_tier | 400 | We do not have that plan. | Use a tier the meter recognises. |
billing_unconfigured | 500 | Payments are not configured here, so no plan was changed. | Deployment configuration. Deliberately distinct from cli_unavailable so an unconfigured billing key never reads as a broken AI editor. |
unsigned_callback | 400 | A billing callback without a valid signature changes nothing. | Sign the callback, or ignore it — an unsigned one is not a payment event. |
payment_unavailable | 200 | SimplePay could not start, so nothing was charged. | No money moved. Retry the checkout. |
legacy_checkout_retired | 410 | That checkout link belongs to a store we no longer serve. | Terminal. Start a new payment through the current flow; an old bookmarked callback must never credit a balance. |
The support desk
GET and POST /api/helpdesk, and the per-store support settings.
| Reason | Status | Cause | What to do |
|---|---|---|---|
ticket_not_found | 400 | No ticket on your desk carries that id. | Read the desk first. Tickets are founder-scoped. |
unknown_action | 400 | This desk takes set, reply, note or draft. | Use one of the four. |
bad_field | 400 | Only a ticket's status, priority or category can be set. | Other fields are not settable through this endpoint. |
resolution_required | 400 | No ticket closes without a documented resolution. | Enforced here, not only in the screen. Supply the resolution text. |
empty_reply · empty_note | 400 | There was nothing to send, or nothing to record. | Send content. |
unknown_op | 400 | This endpoint takes add or delete. | Used by the keyword-rule surface. |
no_question · empty_question · no_query_terms | 200 | A request with no question is not a question. | Ask something. Returned as status refused or abstained depending on the surface. |
Products, outcomes and analysis
The My Products editor, outcome labelling, and analyse-any-product.
| Reason | Status | Cause | What to do |
|---|---|---|---|
invalid_name · invalid_description · invalid_price | 400 | The value is outside the accepted length, or not a valid positive amount. | Validate before sending; the server validates again regardless. |
invalid_images | 400 | The product images are invalid, unsafe or do not belong here. | Images are checked for more than type. Re-encode from an original you control. |
images_too_large | 413 | The product image upload is larger than the editor accepts. | Compress or resize before upload. |
nothing_to_record | 400 | An outcome with no fields records nothing — absence is not a value. | Send at least one field. A bodyless outcome is refused rather than stored as zero. |
invalid_outcome | 400 | Spend and revenue must be non-negative numbers. | Send numbers. |
no_claim_record | 400 | We have no claim record to attach that outcome to. | The claim must exist before an outcome can hang off it. |
nothing_to_analyze | 400 | Send a product name or description. | The analyser needs a subject. |
no_product_id | 400 | A choice with no product named is not a choice. | Name the product. |
not_presearch_qualified | 200 | The product no longer clears the Pre-Search evidence gate. | Evidence aged out. Not recoverable by retrying. |
no_worker | 200 | The 3D worker is not running, so this product stays a photograph. | Environmental. Render the photograph; do not show a broken 3D control. |
no_orders_yet · no_tickets_yet · nothing_to_watch | 200 | The surface is genuinely empty. | States, not errors. An empty support desk means the agent handled everything, not that nothing happened. |
order_not_found | 400 | No order of yours carries that id. | Orders resolve through the same founder-scoped resolver as everything else. |
Sourcing, domains and the proof surface
Supplier discovery, domain purchase, and the public free-fix pages.
| Reason | Status | Cause | What to do |
|---|---|---|---|
not_available | 200 | The domain is unavailable or its price cannot be verified. | One reason for both, because we will not sell a domain on an unverified price. |
sample_unpriced | 409 | The product has no documented seller price for a sample. | No invented figure will be substituted. |
delivery_required | 409 | Sample checkout requires an email and complete delivery address. | Complete the address. |
unknown_report | 404 | The proof token does not name a retained report. | Reports are retained for a period; an old token is not a weaker credential, it is not one. |
unknown_profile | 404 | The token does not name a prospect proof page. | Returned as {"error":"not_found","reason":"unknown_profile"}. |
unknown_page | 404 | That page is not available. | Also the shape returned when a page was removed by its owner. |
unknown_event | 404 | That is not a declared first-party proof event. | Only declared events are accepted. |
detail_required | 400 | A correction must name what is wrong. | Supply the detail. |
schedule_not_found | 200 | There is no active conditional launch for this product. | Nothing scheduled. |
The curriculum layer
Abstentions, not failures. The layer declines rather than answers unsupported.
| Reason | Status | Cause | What to do |
|---|---|---|---|
not_ingested | 200 | The curriculum index has not been built yet. | Environmental. |
no_passage_meets_threshold | 200 | No passage in the curriculum supports that. | An abstention. Show it as one — the layer answered honestly rather than confabulating. |
Ours
One code, one meaning: something on our side broke.
| Reason | Status | Cause | What to do |
|---|---|---|---|
internal | 500 | Something broke on our side; nothing you did caused it. | The dispatch guard's answer when a route raises. Retry once; if it persists, report it with the time and the path. It is the one reason no handler emits on purpose. |
saved_not_published | 500 | Your work was saved, but publication did not complete. | Do not re-enter the data. Retry the publish. |
HTTP statuses, and what each one means here
| Status | Meaning in this API | Retryable |
|---|---|---|
| 200 | Understood. May still carry status: refused — read the body. | Depends on the reason |
| 302 / 303 | A human-facing redirect: referral entry, and sign-in link redemption. The cookie rides the redirect. | n/a |
| 400 | The request itself is malformed or incomplete. | Not without changing it |
| 401 | An account is required and this session has none. | After signing in |
| 404 | A named thing does not exist: an unknown proof token, a removed page, or a preview for a product with no live offer. | No |
| 409 | A conflict with state that already exists — a claim, a pass, a stale proposal. Usually terminal for that subject. | Rarely; read the reason first |
| 410 | A retired endpoint. Permanently gone, with the current path named. | No |
| 413 | An upload exceeds what the editor accepts. | After shrinking it |
| 429 | A usage bucket is exhausted. See Rate limits. | After resets_at |
| 500 | Ours. Always JSON, never an HTML error page — one dispatch guard wraps every route so a crash cannot answer in a format your parser will choke on. | Once, then report |
A handler that ages well
TERMINAL = {
"claimed_by_another_founder", "passed_by_you", "not_shown",
"catalog_exhausted", "passed_out", "legacy_checkout_retired",
}
def handle(response, reasons):
body = response.json()
reason = body.get("reason")
if body.get("status") not in ("refused", "error") and "error" not in body:
return body # a real answer, or a domain state
# The registry is the source of truth for the sentence. Never invent copy for a
# code you do not recognise — print what the server says it means.
sentence = reasons.get(reason, body.get("explanation", "refused"))
if reason in TERMINAL:
raise Terminal(reason, sentence)
if response.status_code == 429:
raise Throttled(reason, sentence, resets_at=body["resets_at"])
if reason == "internal":
raise ServerFault(sentence)
raise Refused(reason, sentence)
reasons = requests.get(f"{BASE}/api/reasons").json()["reasons"]The important line is the fallback. A code your build has never seen still has a correct sentence, because the sentence came from the same server that raised it.
Next: Troubleshooting · Rate limits and quotas · API reference