REFERENCE

Capabilities, scopes, errors, limits.

Base URL https://formulasignal.com. Every route below needs a key except /api/v1/health. The machine-readable version of this page is the OpenAPI document, which is generated from the same table.

Authentication

A bearer key in the Authorization header. X-FormulaSignal-Key carries the same value without the Bearer prefix, for clients whose config format makes that awkward.

curl https://formulasignal.com/api/v1/record/version \
  -H "Authorization: Bearer $FORMULASIGNAL_API_KEY"

A key looks like fsk_{environment}_{keyId}_{secret}. The environment half is readable without a database, so nobody has to ask which one they are holding. The key id is a stable public identifier safe to print in a log line. The secret half is SHA-256 hashed before storage and never stored in the clear, which is why a lost key can only be rotated and never recovered.

A key is never accepted in a query string. That is deliberate rather than an omission: a credential in a URL reaches access logs, browser history and the Referer header of every link a page carries.

Capabilities

18 capabilities. Each has exactly one implementation, and REST and MCP are two doors to it rather than two versions of it.

CapabilityRouteScopeCosts
search_recordPOST /api/v1/record/searchrecord:searchproduct
resolve_productPOST /api/v1/products/resolveproducts:readproduct
get_product_recordGET /api/v1/products/{product_id}products:readproduct
get_formula_historyGET /api/v1/products/{product_id}/historyhistory:readproduct
explain_signalGET /api/v1/signals/{signal_id}signals:readsignal
list_signal_changesGET /api/v1/signalssignals:readsignal
compare_productsPOST /api/v1/products/comparecomparisons:createproduct
get_serving_economicsGET /api/v1/products/{product_id}/serving-economicseconomics:readproduct
get_research_contextPOST /api/v1/research/contextresearch:readingredient
get_regulatory_contextPOST /api/v1/regulatory/contextregulatory:readingredient
get_category_snapshotGET /api/v1/category/snapshotcategory:readsignal
list_ledger_editionsGET /api/v1/ledgerledger:readsignal
get_ledger_editionGET /api/v1/ledger/{edition_id}ledger:readsignal
list_watched_productsGET /api/v1/watchwatch:manageproduct
watch_productPOST /api/v1/watchwatch:manageproduct
unwatch_productDELETE /api/v1/watch/{product_id}watch:manageproduct
get_watch_receiptGET /api/v1/watch/receiptwatch:manageproduct
get_record_versionGET /api/v1/record/versionframework:readnothing

“Costs” is what a successful answer spends from the daily distinct-entity budget. Naming the same product in two requests costs one; naming two products costs two.

Scopes

A scope names a class of question rather than an endpoint, so a second route over the same intelligence does not mint a permission somebody has to be re-granted.

ScopeOn a self-service key
record:searchYes
products:readYes
history:readYes
signals:readYes
comparisons:createYes
economics:readYes
research:readYes
regulatory:readYes
category:readYes
framework:readYes
ledger:readNever. Granted by FormulaSignal against a named account.
watch:manageNever. Granted by FormulaSignal against a named account.

ledger:read is the widest single read in the API: one response carries a month of category intelligence, and it is the paid FormulaSignal for Brands deliverable. watch:manage is the only scope that reaches customer data and the only one that writes, and it is refused outright unless the key’s organization is bound to exactly one account. That binding is what makes “one customer cannot see another watchlist” a property of the schema rather than a check each handler has to remember.

Errors

A refusal uses the same envelope as an answer, because a client parsing two shapes eventually parses one of them wrong. Branch on error.code, never on error.message, which is written for a person and may be reworded.

{
  "request_id": "req_...",
  "capability": "get_product_record",
  "status": "refused",
  "error": {
    "code": "insufficient_scope",
    "message": "...",
    "details": { "required_scope": "products:read" },
    "documentation": "https://formulasignal.com/developers/reference#errors"
  },
  "limitations": [],
  "record_version": "...",
  "disclaimer": "..."
}
CodeHTTPWhat happenedWhat to do
invalid_credentials401The key was missing, malformed, revoked, expired, or its secret did not match.Fix the header. Do not retry unchanged. The message never says which of those it was, so an attacker cannot learn that a key exists.
insufficient_scope403The key authenticated but does not hold the scope this capability needs.Grant the scope, or stop calling this capability with this key.
plan_entitlement_required403The organization's plan does not include this capability at all.A scope grant will not help. Talk to us.
key_suspended403The key or its organization is suspended.Write to hello@formulasignal.com. Retrying does nothing.
rate_limited429Too many requests in the minute, hour or day window.Read Retry-After and RateLimit-Policy, wait that many seconds, retry once.
extraction_limit_reached429The daily budget for distinct products, Signals or ingredients is spent.Waiting for the next UTC day is the only thing that helps. Rewording the question or narrowing the window spends more of the same budget.
invalid_request400The arguments did not validate, or a cursor was not one we issued.Read error.details, which names the field.
payload_too_large413The body exceeded 8192 bytes.Send less. No capability here takes a large body.
not_found404FormulaSignal holds no record with that identifier.Report it as an absence in the Record, never as a fact about the product. An unapproved Signal id answers identically to an invented one, on purpose.
unsupported_capability404No capability by that name exists.Check the list on this page. There is no export or enumeration capability and there will not be one.
method_not_allowed405Right path, wrong HTTP method.The method is in the table above.
internal_error500We broke. The request id is in the body and in our logs.Retry once with backoff. Quote the request id if it persists.

A refusal is not the only way to get told no. A 200 carrying status of unsupported, ambiguous, under_review, partial or stale is a real answer about the Record. Those are described in the evidence model, and collapsing them into “failed” is the most common way an integration ends up publishing something false.

Rate limits and the daily budget

Two separate controls. The rate limit stops a burst. The daily distinct-entity budget is what actually protects the Record, because a rate limit does nothing to a patient caller reading a few products a day for a month.

EVERY RESPONSE CARRIES

RateLimit-Limit: 30
RateLimit-Remaining: 27
RateLimit-Reset: 41
RateLimit-Policy: minute
Retry-After: 41          # on a 429 only
  • Free plan rate: 30 a minute, 500 an hour, 2000 a day.
  • Free plan daily breadth: 15 distinct products, 15 Signals, 25 ingredients, per UTC day. Repeating a question about the same product costs nothing more.
  • Counters are per organization, not per key. Holding three keys does not give you three budgets. Rate limits stay per key on purpose, because a burst ceiling is per integration.

Iterating through products, aliases or date windows to assemble a copy of the Record is refused, scored, and can suspend the key. That is stated plainly rather than hidden, because a caller who knows the rule can build inside it.

Pagination

list_signal_changes and get_category_snapshot page with an opaque cursor. Follow next_cursor until it is null. A cursor we did not issue is invalid_request, never a silently reset page.

The Signal feed orders on release date, not on when the change happened, and that distinction is the whole reason polling works. C4 Original’s change window closed in February 2024 and the Signal was released in July 2026. A feed ordered by the change would have buried it two years deep and no poller would ever have seen it.

Versioning

The contract version is v1 and it is in the path. What that promises:

  • Additive changes ship without notice. A new field, a new capability, a new enum member on a field documented as open. Parse defensively: ignore fields you do not know.
  • A breaking change gets a new path. Removing a field, changing a type, changing what a value means, or narrowing an enum. /api/v1 keeps working.
  • Deprecations are announced before they bite. The changelog is the record, and a deprecated field keeps returning its old value for at least 90 days after the notice.
  • Identifiers are stable. A product_id and a signal_id are permanent. A product that is retired keeps its id and says it is retired; it does not vanish.
  • A correction never changes an id or a release date. When FormulaSignal amends or withdraws a finding, the Signal keeps its address and its release date and says what happened. Nothing resends and no poller sees it twice.

Caching and CORS

Every capability response is private, no-store. Answers are entitlement-specific, so no shared cache may hold one. Cache in your own application instead, keyed on record_version: two answers sharing it came from one committed Record state.

This is a server-side API. No CORS headers are sent on /api/v1, so a browser cannot call it cross-origin, and that is the intent rather than an oversight: a key in a browser is a key in the page source. The OpenAPI document is the one exception and is world-readable, since a specification is not a credential.