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.
| Capability | Route | Scope | Costs |
|---|---|---|---|
search_record | POST /api/v1/record/search | record:search | product |
resolve_product | POST /api/v1/products/resolve | products:read | product |
get_product_record | GET /api/v1/products/{product_id} | products:read | product |
get_formula_history | GET /api/v1/products/{product_id}/history | history:read | product |
explain_signal | GET /api/v1/signals/{signal_id} | signals:read | signal |
list_signal_changes | GET /api/v1/signals | signals:read | signal |
compare_products | POST /api/v1/products/compare | comparisons:create | product |
get_serving_economics | GET /api/v1/products/{product_id}/serving-economics | economics:read | product |
get_research_context | POST /api/v1/research/context | research:read | ingredient |
get_regulatory_context | POST /api/v1/regulatory/context | regulatory:read | ingredient |
get_category_snapshot | GET /api/v1/category/snapshot | category:read | signal |
list_ledger_editions | GET /api/v1/ledger | ledger:read | signal |
get_ledger_edition | GET /api/v1/ledger/{edition_id} | ledger:read | signal |
list_watched_products | GET /api/v1/watch | watch:manage | product |
watch_product | POST /api/v1/watch | watch:manage | product |
unwatch_product | DELETE /api/v1/watch/{product_id} | watch:manage | product |
get_watch_receipt | GET /api/v1/watch/receipt | watch:manage | product |
get_record_version | GET /api/v1/record/version | framework:read | nothing |
“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.
| Scope | On a self-service key |
|---|---|
record:search | Yes |
products:read | Yes |
history:read | Yes |
signals:read | Yes |
comparisons:create | Yes |
economics:read | Yes |
research:read | Yes |
regulatory:read | Yes |
category:read | Yes |
framework:read | Yes |
ledger:read | Never. Granted by FormulaSignal against a named account. |
watch:manage | Never. 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": "..."
}| Code | HTTP | What happened | What to do |
|---|---|---|---|
invalid_credentials | 401 | The 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_scope | 403 | The key authenticated but does not hold the scope this capability needs. | Grant the scope, or stop calling this capability with this key. |
plan_entitlement_required | 403 | The organization's plan does not include this capability at all. | A scope grant will not help. Talk to us. |
key_suspended | 403 | The key or its organization is suspended. | Write to hello@formulasignal.com. Retrying does nothing. |
rate_limited | 429 | Too many requests in the minute, hour or day window. | Read Retry-After and RateLimit-Policy, wait that many seconds, retry once. |
extraction_limit_reached | 429 | The 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_request | 400 | The arguments did not validate, or a cursor was not one we issued. | Read error.details, which names the field. |
payload_too_large | 413 | The body exceeded 8192 bytes. | Send less. No capability here takes a large body. |
not_found | 404 | FormulaSignal 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_capability | 404 | No 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_allowed | 405 | Right path, wrong HTTP method. | The method is in the table above. |
internal_error | 500 | We 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/v1keeps 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_idand asignal_idare 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.