REST API Reference
Reference for the Fond REST API: authenticate with an API key, retrieve brand-visibility analytics, and trigger query runs under /api/v1/.
The Fond REST API lets you get analytics data and trigger runs from your own code. All endpoints are under /api/v1/.
Authentication
Each request must include an API key. An admin or owner can create keys in the dashboard at Settings > API Keys.
Send the key in one of these headers:
Authorization: Bearer sk-your-key-hereX-API-Key: sk-your-key-hereFond shows a key only once, when you create it, so store it securely.
Error responses
A key that is invalid, revoked or expired returns 401:
{ "error": "Invalid API key" }The three cases look the same on purpose, so a revoked key does not show that it was ever real.
A missing or malformed Authorization header returns 401 with a different
body, { "error": "Missing or invalid API key" }.
A read-only key used with a method that changes state returns 403:
{ "error": "This API key is read-only" }Read-only keys can use only GET and HEAD, so they cannot call
POST /websites/{websiteId}/runs. That call uses one of the runs that your plan
allows. It does not use up your question limit, which is a standing limit.
Give a read-only key to an integration that must not spend, such as a BI dashboard or a reporting job.
A 401 means that the credential is not usable, and a new credential can help.
A 403 means that Fond refused what a valid key tried to do. A retry of a
read-only write with the same key always fails.
The plan run limit on POST /runs is the only 403 that you can retry, and
only on a recurring plan. A recurring plan counts runs over a rolling window, so
the limit clears as older runs leave the window.
On a one-time plan (audit tier), the same limit is a lifetime cap with no
window. Its 403 never becomes retryable, so a client that retries it on a
schedule loops without end.
Only a plan change, or one replacement for a stranded run, clears the cap. A stranded run is a terminal run with a failure that Fond cannot dispatch again. This occurs when Fond no longer collects from the provider the way it measured that failure.
A run whose failures Fond can all run again is not stranded, and it still consumes the cap. Fond excuses exactly one stranded run per site.
Endpoints
List websites
GET /api/v1/websitesReturns all websites in the organization whose setup status is COMPLETE.
Response
{
"websites": [
{
"id": "clx...",
"url": "https://example.com",
"name": "Example Store",
"createdAt": "2026-01-15T10:30:00.000Z"
}
]
}List runs
GET /api/v1/websites/:websiteId/runsQuery parameters
| Param | Type | Default | Description |
|---|---|---|---|
status | string | — | Filters by status: PENDING, RUNNING, PROCESSING, ANALYZING, COMPLETED, COMPLETED_WITH_ERRORS, FAILED |
limit | integer | 20 | Results per page (1-100) |
offset | integer | 0 | Number of results to skip |
Response
{
"runs": [
{
"id": "clx...",
"runDate": "2026-03-06T00:00:00.000Z",
"status": "COMPLETED",
"triggeredBy": "MANUAL",
"name": "Weekly check",
"totalQueries": 24,
"totalResults": 72,
"failedResults": 0,
"locale": "en-US",
"startedAt": "2026-03-06T12:00:00.000Z",
"completedAt": "2026-03-06T12:05:30.000Z",
"createdAt": "2026-03-06T12:00:00.000Z"
}
],
"total": 42,
"limit": 20,
"offset": 0
}Trigger a run
POST /api/v1/websites/:websiteId/runsStarts a new query run for the website. Returns 409 if a run is already in progress.
Request body
The body is optional. If you send one, it is JSON.
| Field | Type | Description |
|---|---|---|
name | string | Optional label for the run (1-100 characters), returned by this call and both GET run endpoints. null when not set. |
queryIds | string[] | Runs only these queries, at most 500 for each request. Omit it to run all queries. Fond drops an id that is not an active query on this website, or is beyond your plan's question limit, and runs the rest. Only an empty remainder returns 400, so a partly wrong list still returns 201. Compare totalQueries in the response with the number of ids you sent. |
Example
curl -X POST https://your-domain.com/api/v1/websites/clx.../runs \
-H "Authorization: Bearer sk-your-key" \
-H "Content-Type: application/json" \
-d '{"name": "Weekly check"}'Response
The status is 201 Created.
{
"id": "clx...",
"status": "RUNNING",
"triggeredBy": "API",
"name": "Weekly check",
"totalQueries": 24,
"totalResults": 0,
"failedResults": 0,
"startedAt": "2026-03-06T14:00:00.000Z",
"completedAt": null
}Error responses
| Status | Reason |
|---|---|
400 | Invalid body, no valid queries to run, or no provider enabled for the website's plan |
403 | Read-only key, or your plan has reached its run limit for the period |
404 | Website not found or not in your organization |
409 | A run is already in progress, or your plan is not fully configured |
503 | The server has no active prompt version. Contact support. |
triggeredBy values
Each run records what started it. A run that this endpoint creates is API.
| Value | Meaning |
|---|---|
CRON | The daily scheduler started it |
MANUAL | Anything that is neither the scheduler nor a current call to this endpoint |
API | A call to this endpoint started it |
MANUAL covers Run Now in the dashboard, the onboarding auto-run, demo
provisioning, and runs that this endpoint created before API existed. You
cannot tell these apart, and MANUAL does not mean that a person started the run.
Get run details
GET /api/v1/websites/:websiteId/runs/:runIdReturns a single run with summary statistics.
Response
{
"id": "clx...",
"runDate": "2026-03-06T00:00:00.000Z",
"status": "COMPLETED",
"triggeredBy": "MANUAL",
"name": "Weekly check",
"totalQueries": 24,
"totalResults": 72,
"failedResults": 0,
"locale": "en-US",
"modelVersions": { "gemini": "gemini-2.0-flash", "openai": "gpt-5-mini" },
"repetitions": 3,
"startedAt": "2026-03-06T12:00:00.000Z",
"completedAt": "2026-03-06T12:05:30.000Z",
"errorMessage": null,
"createdAt": "2026-03-06T12:00:00.000Z",
"summary": {
"totalResults": 72,
"mentionCount": 45,
"citationCount": 12
}
}List results
GET /api/v1/websites/:websiteId/runs/:runId/resultsReturns paginated query results for a run.
Query parameters
| Param | Type | Default | Description |
|---|---|---|---|
provider | string | — | Filters by provider (for example, gemini or openai) |
queryId | string | — | Filters by one query |
limit | integer | 50 | Results per page (1-100) |
offset | integer | 0 | Number of results to skip |
Response
{
"results": [
{
"id": "clx...",
"queryId": "clx...",
"provider": "gemini",
"model": "gemini-2.0-flash",
"isMentioned": true,
"isCited": false,
"isLinked": false,
"mentionPosition": 2,
"mentionContext": "...recommended by Example Store...",
"sentiment": "POSITIVE",
"extractedUrls": [],
"citations": {},
"followUpQuestions": ["What products does Example Store offer?"],
"brandInFollowUp": true,
"confidence": null,
"groundingUrls": ["https://example.com/products"],
"errorMessage": null,
"createdAt": "2026-03-06T12:01:00.000Z",
"query": {
"text": "best online stores for electronics",
"queryType": "CATEGORY",
"categoryTier": "SAFETY",
"queryFocus": "PRODUCT"
}
}
],
"total": 72,
"limit": 50,
"offset": 0
}Response fields
Each object in results[] has these fields:
| Field | Type | Description |
|---|---|---|
id | string | Result ID. |
queryId | string | ID of the query this result answers. |
provider | string | LLM provider (claude, gemini, openai, perplexity, google_ai_mode). These are the values that can appear. A run uses only the providers that the plan and the site configuration allow. |
model | string | null | The model that gave the answer (for example, gemini-2.0-flash). Usually the model that Fond chose for the run. For an assistant that does not let Fond choose, the model that it reported, or null (unknown, not an error). |
isMentioned | boolean | Whether the response mentions the brand. |
isCited | boolean | Whether the response cited the brand as a source. |
isLinked | boolean | Whether the response linked to the brand. |
mentionPosition | integer | null | Which third of the response the brand first appears in: 0 = top third, 1 = middle, 2 = lower. null when the response does not mention the brand. Not a paragraph index, despite the name. (Results from before 2026-06-13 can carry a raw paragraph index.) |
mentionContext | string | null | Excerpt of the sentence where the brand first appears. Fond removes bold, italic and link syntax. Other Markdown stays, so escapes such as \[1\], code spans and strikethrough can appear. (Results from before 2026-09-22 also keep emphasis and link source, for example **Brand**.) |
sentiment | string | null | Sentiment toward the brand: POSITIVE, NEUTRAL, or NEGATIVE. null in three cases that it cannot tell apart. These are no mention, a mention that the run has not classified yet, or a literal parser match with no brand entry. Wait for a terminal run status and read isMentioned before you take null as no sentiment. |
extractedUrls | string[] | URLs found in the response text. |
citations | object | null | Citations that the provider supplied, in a shape that changes with the provider. {} when it supplied none. null only on a failed row, which has errorMessage. |
followUpQuestions | string[] | Follow-up or related questions that the response suggests. |
brandInFollowUp | boolean | Whether the brand appears in any follow-up question. |
confidence | number | null | Confidence in the brand-mention classification (0–1). Nothing fills it today. Expect null, and do not depend on it. |
groundingUrls | string[] | URLs that the provider used to ground its answer (web-search results). |
errorMessage | string | null | Set if the provider call failed for this result. Otherwise null. |
createdAt | string | ISO 8601 timestamp. |
query | object | The query this result answers (fields below). |
In the query object, queryType, categoryTier and queryFocus are the
values that applied when the run executed. If you retag a query, its earlier
runs stay in their old buckets. Rows from before Fond versioned each result
report today's values. text cannot change, because editing a query's wording
creates a new query.
| Field | Type | Description |
|---|---|---|
text | string | The query text sent to the provider. |
queryType | string | One of BRAND_REPUTATION, CATEGORY, COMPARISON, PROBLEM_SOLVING, LOCAL, BRAND_IDENTITY. |
categoryTier | string | How competitive the query is: SAFETY (closest to the brand, least competition), then STRONG_REACH, REACH, and FAR_REACH (category-level). Not an intent tier. Intent is queryType. |
queryFocus | string | PRODUCT or BRAND. |
At the top level, total is the number of results that match the filter, for pagination. limit and offset repeat the values from the request.
Pagination
The runs and results endpoints accept limit and offset and return a total count. GET /websites is not paginated. It returns every completed website under websites, with no total, limit or offset.
Both paginated endpoints sort by createdAt and then by id. Runs are newest first, and results are oldest first. The id tie-break keeps rows that share a timestamp in a fixed order across pages.
The results of a finished run do not change, so their pages are stable. In the runs list, a run that arrives during your walk moves each later page by one. You then see one run twice and miss a different run.
When you page the runs list, skip ids that you already saw. If total increased between your first and last page, a run arrived during your walk. Only a new walk tells you which run it was.
To page through the runs or results endpoint:
# Page 1
GET /api/v1/websites/:id/runs?limit=20&offset=0
# Page 2
GET /api/v1/websites/:id/runs?limit=20&offset=20Rate limits
The API enforces no rate limits today. Do not poll run status in a tight loop.
Common workflows
Poll for run completion
set -euo pipefail # a failed request must stop the poll, not be polled around
# 1. Trigger a run
RUN_ID=$(curl -fsS -X POST .../runs -H "Authorization: Bearer $KEY" | jq -r '.id // empty')
[ -n "$RUN_ID" ] || { echo "no run id in the response" >&2; exit 1; }
# 2. Poll status every 30 seconds
while true; do
STATUS=$(curl -fsS .../runs/$RUN_ID -H "Authorization: Bearer $KEY" | jq -r '.status // empty')
[ -n "$STATUS" ] || { echo "no status in the response" >&2; exit 1; }
echo "Status: $STATUS"
case "$STATUS" in COMPLETED|COMPLETED_WITH_ERRORS|FAILED) break;; esac
sleep 30
done
# 3. Fetch results
curl -fsS .../runs/$RUN_ID/results -H "Authorization: Bearer $KEY" | jq .Export every result from one run
set -euo pipefail # a failed page, a bad body or a failed write all stop here
# Only a finished run can be exported whole. `completedAt` is written with the
# run's terminal status and cleared again by a retry, so it is the one field
# that answers whether this run is still moving.
RUN=$(curl -fsS ".../runs/$RUN_ID" -H "Authorization: Bearer $KEY") \
|| { echo "could not read run $RUN_ID" >&2; exit 1; }
echo "$RUN" | jq -e '.completedAt != null' > /dev/null \
|| { echo "run $RUN_ID has not finished; poll it first" >&2; exit 1; }
OFFSET=0
: > results.jsonl # start empty, so a re-run replaces rather than appends
while true; do
RESPONSE=$(curl -fsS ".../runs/$RUN_ID/results?limit=100&offset=$OFFSET" \
-H "Authorization: Bearer $KEY") \
|| { echo "request failed at offset $OFFSET" >&2; exit 1; }
echo "$RESPONSE" | jq -c '.results[]' >> results.jsonl
TOTAL=$(echo "$RESPONSE" \
| jq -r '.total | numbers | select(. >= 0 and floor == .)')
[ -n "$TOTAL" ] || { echo "no usable total in the response" >&2; exit 1; }
OFFSET=$((OFFSET + 100))
if [ "$OFFSET" -ge "$TOTAL" ]; then break; fi
doneExport only a finished run. The results endpoint serves the rows that exist when
you ask, with no run-status filter. A running run continues to add rows and to
fill sentiment, so its export is a snapshot that looks complete.
The script empties results.jsonl first, so you can safely run it twice.
Without that, a second run appends a second export, and nothing in the file
shows it.
The script exports the one run that $RUN_ID names. To export a website's whole
history, loop over the paginated GET /websites/:websiteId/runs and run the
inner loop for each run id. Truncate the file once, before that outer loop.
The script writes one JSON object per line. jq -c '.results[]' unwraps each
response, because a file of whole responses has several top-level objects, and
a JSON parser rejects it.