For agents
For coding agents
The whole API on one self-contained page — endpoints, shapes, error codes, pagination rules, and canonical examples.
Condensed reference for the Sprouts Data Platform API, optimized for LLM coding agents. Self-contained: everything needed to integrate is on this page.
Base URL and auth#
Base URL: https://api.sproutsdata.ai
Header: X-API-Key: $SPROUTS_API_KEY (sk_dsp_live_... or sk_dsp_test_...)
Requests: Content-Type: application/json (unknown body fields => 422)
Errors: application/problem+json (switch on `code`, never on text)
Rule: keys are SERVER-SIDE ONLY — Origin header + X-API-Key => 403 auth.browser_origin
Rule: a key goes in X-API-Key ONLY. `Authorization: Bearer` is for OAuth delegation tokens;
an sk_dsp_ key sent as Bearer => 401 auth.unauthorized (the MCP server is the exception:
it accepts a key as Bearer and forwards it as X-API-Key)
Test keys (sk_dsp_test_) hit the same real corpus, cost zero credits, and return meta.environment: "test". An agent run on a test key carries a billing_bypassed degradation entry.
Scopes (five; nothing broader exists): dsp.search.read, dsp.entities.read, dsp.usage.read, dsp.agents.read, dsp.agents.write. dsp.agents.write also grants read on runs. A key created in the dashboard starts with dsp.search.read and dsp.entities.read ticked and a 90-day expiry; the key-creation API's default (no scopes sent) is all five and 365 days. A 403 auth.insufficient_scope lists every scope the route accepts ("This credential holds none of: …"); any one of them is enough.
Endpoints#
| Method | Path | Scope | Metered action | Caps |
|---|---|---|---|---|
| POST | /v1/search | dsp.search.read | dsp.search.fast | .auto | .deep | limit 1–100; query ≤ 2000 chars; domains lists ≤ 100 |
| POST | /v1/similar | dsp.search.read | dsp.similar | 1–10 anchors; limit 1–100; company-only |
| POST | /v1/entities/companies | dsp.entities.read | dsp.entities | 1,000 ids; 50 when by set |
| POST | /v1/entities/persons | dsp.entities.read | dsp.entities | 1,000 ids; 50 when by set |
| POST | /v1/entities/{patents,news,trends,market-reports,fundings,academic-reports,job-openings} | dsp.entities.read | dsp.entities | 100 ids; no by |
| POST | /v1/relationships | dsp.search.read | dsp.relationships | NL question or explicit relation; limit 1–100; cursor-paged |
| POST | /v1/relationships/reach | dsp.search.read | dsp.relationships | One person (LinkedIn URL) × one target company |
| POST | /v1/relationships/institutions | dsp.search.read | dsp.relationships | Exactly 2 companies; limit 1–1000; not paged — read truncated |
| POST | /v1/relationships/ask | dsp.search.read | dsp.relationships.ask | A sentence; server picks the route and reports it. query ≤ 2000; limit 1–100 |
| POST | /v1/org-chart, …/departments, …/locations | dsp.search.read | dsp.org_chart | One company by domain xor linkedin_url; a matrix, not a reporting tree |
| POST | /v1/agents/runs | dsp.agents.write | dsp.agent.run.{effort} — a hold at the tier price on create; charged only if the run succeeds, when the finished run is first read with GET …/{run_id} or cancel | Idempotency-Key required (≤ 255 chars); effort minimal–xhigh or auto (holds 85, charged at the effort it ran; filed under high). See "POST /v1/agents/runs" below |
| GET | /v1/agents/runs, …/{run_id}, …/{run_id}/results, …/{run_id}/events | dsp.agents.read or dsp.agents.write | free | List: limit 1–100 (default 25), newest first, no cursor. Results: cursor + limit 1–100 (default 25). Events: server-sent events, resume with Last-Event-ID |
| POST | /v1/agents/runs/{run_id}/cancel | dsp.agents.write | free | Cancelling a running run releases its hold; cancelling a finished run leaves it finished |
| GET | /v1/coverage | dsp.search.read | free | The segment parameter is accepted but does not narrow the answer yet |
| GET | /v1/sorts/{category} | dsp.search.read | free | The sort vocabulary; complete and unpaginated |
| GET | /v1/search/filters/{name} | dsp.search.read | free | company/person filter values; search, page, and level/parent (mutually exclusive) for two-level filters |
| GET | /v1/search/filters/{category}/{name} | dsp.search.read | free | The same for a document category — separate route because the namespaces collide |
| POST/GET | /v1/feedback | dsp.search.read or dsp.usage.read | free | Report a wrong/missing/stale result against a request_id or run_id; GET /v1/feedback lists reports and is the one offset-paginated route |
| GET | /v1/feedback/tags | none | free | The tag vocabulary; needs no credential |
| PUT/GET | /v1/feedback/votes | dsp.search.read or dsp.usage.read | free | Thumbs on a result set (like/dislike) or a flag on one result (flag), against a request_id (an agent run's run_id for run results). Optional reason_code — result set: not_relevant, missing_expected, too_few, wrong_or_outdated, other; result: wrong_entity, outdated, duplicate, missing_info, other. changed: false = that vote was already active. GET takes request_id |
| GET/POST | /v1/lists; PATCH/DELETE /v1/lists/{list_id}; GET/POST/DELETE /v1/lists/{list_id}/items | dsp.search.read or dsp.entities.read | free | Saved lists for the workspace. Items are {entity_category, entity_id} (a company's domain, a person's LinkedIn URL, …). GET items: cursor + limit 1–100 (default 25), each item hydrated with its current record (how many fields depends on field_tier: free or unlocked); a record that can't be shown comes back available: false with unavailable_reason. A free workspace may have a cap on saved items (saved_items_cap on GET /v1/lists; null = no cap, and a paid workspace is never capped); going over it => 409 lists.item_cap_reached with extra {cap, saved, adding} |
| POST | /v1/lists/{list_id}/export, /v1/search/export | dsp.search.read or dsp.entities.read | — | CSV export is not switched on yet: both answer 404 export.not_enabled today |
| POST | /v1/members/resolve | dsp.search.read or dsp.agents.write | free | {email} => is this a member of the credential's own organisation? found: false is a 200 answer, not an error; any non-2xx means "could not determine" |
| GET | /v1/credits | dsp.usage.read | free | — |
| GET | /v1/keys/usage | dsp.usage.read | free | API-key-only; granularity=hour|day|month|total, from, to |
| GET | /v1/usage/history | dsp.usage.read | free | Calling credential's own runs; cursor-paged, limit 1–100, action_type (repeatable), from, to |
| GET | /v1/usage/history/{run_id} | dsp.usage.read | free | One run: request, resolved plan, stored page-1 snapshot (30 days) — reading it never re-charges |
| GET | /v1/usage/metrics | dsp.usage.read | free | Per-day per-action counts, ok/failed split, credits; window defaults to last 30 days |
| POST | /v1/billing/top-up/intents | dsp.usage.read | free | No body. Returns {topup_id, url, expires_at}: hand url to a person to pay in the dashboard; a key can never buy |
| GET | /v1/billing/top-up/intents/{topup_id} | dsp.usage.read | free | Poll until status is completed (credits_added set); 404 payment.topup_not_found once expired (15 min) |
Pricing (credits, to 3 decimals; every billed response states its cost in meta.credits_charged)#
Basic search (mode fast) 0.5 for the first 10 results, then 0.08 per extra result
AI search (mode auto) 0.595 for the first 10 results, then 0.085 per extra result
Similar / relationships / reach / ask 2 for the first 10 results, then 1 per next 10 (part blocks count as full)
Ask routed to shared institutions, /relationships/institutions 2 flat
Record lookup (/v1/entities/*) 1 per record FOUND; found:false ids are free
Org chart (/v1/org-chart, …) 1 flat; an org chart call that finds nothing is free
Agent run by effort minimal 1.02 · low 2.125 · medium 8.5 · high 42.5 · xhigh 85 — charged only if the run SUCCEEDS
auto holds 85, then is charged the effort it ran (today: high, 42.5)
Deep search (mode deep) the agent price of the effort it ran (today: minimal, 1.02), even with no rows
Free coverage, credits, usage, history, filters, sorts, run reads, feedback, votes, lists, members, top-up routes
- Charged only once the response is ready, for what it delivered. Errors are returned before the charge, with one exception: a 503
credits.unavailable(the billing service could not confirm the charge) MAY have been charged — retry it with the sameIdempotency-Key. - A per-result or per-block call that returns nothing costs 0, and so does an org chart call that finds nothing. Other flat calls (institutions, deep, a successful run) are charged whatever they return. A failed or cancelled run costs nothing.
- One search keeps one count across its pages: 10 rows then 15 rows cost what 25 rows in one page cost (Basic: 1.7).
- Re-sending a cursor you already used is free (
credits_charged: 0). A request with no cursor is a NEW search and pays the base again — keep page 1 rather than re-requesting it. - Re-sending an old cursor with a DIFFERENT
limitre-bills the rows in the new span.
Categories (closed set; request value == results[].category): company, person, patent, news_article, trend, market_report, funding_round, academic_report, job_opening. Which are wired varies per environment — probe GET /v1/coverage; unwired categories 501 on search.
Idempotency-Key
- Honoured on
/v1/search,/v1/similar,/v1/relationships(and/reach,/institutions,/ask),/v1/org-chart(all three) and all nine/v1/entities/*routes. Optional there. - Same key + same credential + same route + same body + same UTC day = the same billing session. A retry that returns the same rows is not charged again. A retry that returns a DIFFERENT number of rows (an AI search can vary) is charged again in full, not just for the difference. The same key with a different body silently starts a new session (no error).
POST /v1/agents/runsis different: the key is REQUIRED (400request.invalidwithout it, or over 255 characters). Reusing a key returns the ORIGINAL run with 200 instead of 202, whatever the body says, and starts and charges nothing. Run keys never expire and are scoped to the workspace, not the day — use a new key per intended run.
Request skeletons#
POST /v1/search
{
"query": "fintech companies in berlin", // optional IF filters given; at least one required
"mode": "fast", // fast (no LLM) | auto (LLM infers filters) | deep (company/person only; one page, no cursor; filters, domains and sort not applied yet)
"category": "company", // default company
"filters": { // shapes vary per category; unknown keys => 422
"industry": {"include": ["Software & Technology"]}, // TermsFilter
"employee_count": {"gte": 500, "lte": 5000}, // RangeFilter
"last_funding_date": {"period": {"gte": "2024-01-01"}} // DateFilter — bounds MUST nest under "period"
}, // person: "tenure" = years in the CURRENT role; "years_worked" = whole
// years across every job held ({"lte": 3} = early career; {"gte": N} =
// some job started at least N years ago)
"include_domains": ["adyen.com"], // company/person/job_opening ONLY; max 100
"exclude_domains": [],
"sort": [{"field": "employee_count", "direction": "desc"}], // <=3 clauses, strongest first; direction defaults "desc"
// needs an explicit `category`; duplicate field => 422
// vocabulary: GET /v1/sorts/{category} — NEVER hard-code it
"limit": 10, // 1..100, default 10 (what a search's base price covers)
"cursor": null // from previous next_cursor
}People have no embeddings. A person query that describes someone ("engineers who care about developer experience") cannot be ranked by meaning: the results match the filters and the parsed query, but their order does not reflect how well each fits the description, and degradation[] says so (reason: "mode_downgraded", impact: "precision"). A company description inside a person query ("people at climate-tech startups") is matched through the employer's embedding. resolved_search.sort echoes the sort that was applied: yours, or one inferred from the question when you sent none.
POST /v1/relationships/ask
{
"query": "who at Adyen do we know from Stripe", // 1..2000 chars; the only required field
"limit": 10, // 1..100, default 10
"cursor": null // carries the plan — paging never re-reads the sentence
}
// Response: {"route": "relationships"|"reach"|"institutions"|"compare"|"fan_out",
// "reading": {"span", "searches", "warnings": []},
// + EXACTLY ONE of relationships|reach|institutions|compare|fan_out}
// READ `route` FIRST. The five shapes are different answers, not variants of one.
// `compare` and `fan_out` are never paginated: next_cursor null means "no pages", not "last page".
// An ambiguous question is REFUSED (422), never guessed at. A name matching two companies is
// 422 relationship.anchor_ambiguous with extra.anchor + extra.candidates.POST /v1/similar
{
"anchors": [ // 1..10
{"value": "adyen.com", "by": "domain", "weight": 1.0} // by: domain | li_url | name (no id)
],
"combine": "and", // and = like ALL together; or = like ANY one
"category": "company", // anything else => 422
"limit": 10, // default 10: one block of the look-alike price
"cursor": null
}If every anchor fails to resolve => 422 request.invalid. Partial resolution succeeds silently. Anchors are excluded from their own results.
POST /v1/entities/companies (persons analogous)
{
"ids": ["adyen.com", "checkout.com"], // without `by`: internal ids
"by": "domain", // auto | domain | li_url | name; persons: no domain
"fields": ["account_name", "industry"] // omit for full record
}Document routes: patents, trends, academic-reports take no by (the id
is the patent number / trend name / DOI). news, market-reports, fundings,
job-openings take by: "id" | "name" — and the server's default is "name",
so always send by: "id" explicitly for internal ids, or the lookup searches
titles and finds nothing.
Chaining a search hit into a full record: a hit's id is NEVER a lookup id
(it is the company's domain or a 16-hex hash). Send a company hit's domain to
POST /v1/entities/companies with "by": "domain", and a person hit's
linkedin_url to POST /v1/entities/persons with "by": "li_url".
POST /v1/agents/runs
// Header: Idempotency-Key: <one new value per intended run, ≤ 255 chars> REQUIRED
{
"query": "series B fintechs in Germany that sell to banks", // 1..4000 chars; the only required field
"category": "company", // company | person; default company
"effort": "medium", // minimal | low | medium (default) | high | xhigh | auto
"criteria": ["sells to banks"], // ≤ 10 prose checks applied to every row
"enrichments": ["cto_name"], // ≤ 20 column names; listed in `columns`, value keyed by name on each row
"exclusion": ["adyen.com"] // ≤ 1,000 entity ids to leave out
}
// Unknown fields => 422. 202 {run_id, status, meta}; a reused Idempotency-Key => 200 + the ORIGINAL run.
// Statuses: pending | running | completed | failed | cancelled.Lifecycle and billing, in order:
- Create places a hold for the tier price (
auto: 85). If the hold can't be placed: 402credits.insufficient. - Poll
GET /v1/agents/runs/{run_id}untilstatusiscompleted,failedorcancelled(or followGET …/{run_id}/events, server-sent events, resuming withLast-Event-ID; a failure after the stream opens arrives as a frame, not a status). - The FIRST read of the finished run with
GET /v1/agents/runs/{run_id}(or a cancel) settles the charge:completedis charged the tier price (auto: the tier ineffort_ran);failedandcancelledare charged nothing. Reading results, listing runs and the events stream do NOT settle. - The hold lasts 35 minutes. A finished run nobody reads with
GET …/{run_id}before then is never charged and reportscredits_charged: 0(usage history shows it asunsettled). So always finish with that read. credits_chargedisnulluntil the run has settled. Never readnullas0.- Rows:
GET …/{run_id}/results(cursor +limit1–100, default 25, free). Each row has anid(the company's domain when it has one, otherwise a 16-hex hash) plus its fields;totalis the answer's size;columnslists the enrichment columns.
GET /v1/agents/runs/{run_id} returns run_id, status, stop_reason, coverage, degradation, insights, insight_sources, summary, exclusions, credits_charged, effort, effort_ran, error, meta. An unknown run id, or another workspace's, is 404 request.invalid. Limits on starting a run: 429 quota.concurrency_exceeded (more than 5 runs being started at the same moment on one credential; Retry-After: 30) and 429 quota.entity_ceiling (the credential has been shown 5,000,000 distinct entities in the last 30 days, search and run results both counting; extra {seen, limit}; contact us to raise it). Full reference: https://dashboard.sproutsdata.ai/docs/api-reference/agent-runs and https://dashboard.sproutsdata.ai/docs/guides/agent-runs.
Response envelope (all list endpoints)#
{
"results": [ /* hits: {id, category, score, ...record fields flattened} — entities: EntityHit below */ ],
"total": 10000, // match count
"total_is_estimate": true, // true => total is a FLOOR ("10000+"), never render as exact
"has_more": true, // invariant: has_more == (next_cursor != null)
"next_cursor": "eyJ...", // opaque + signed; pass back as `cursor`
"resolved_search": { // how the query was interpreted (null on entities)
"parser_version": "none", // "none" when no LLM ran (fast, similar, cursor pages)
"mode": "fast", "category": "company", "query": "...",
"filters": {}, // the EFFECTIVE filters searched
"filters_source": "none", // caller | parser | caller+parser | none
"semantic_query": null, "sort": null, "conflicts": [], "overrides": []
},
"suggestion": null, // or {"kind": "search"|"agentic", "category": …|null, "message": "…"}
// ADVISORY: the search asked for is the one that ran. `agentic` => POST /v1/agents/runs
// (a paid, multi-step run: see "POST /v1/agents/runs" above)
"coverage": null, // null = NOT MEASURED (fast/auto never enumerate), never "zero coverage".
// When set: {universe, universe_is_exact, enumerated, enumeration_recall,
// universe_exhausted, truncated_by, constraints_unmet[]}
// enumeration_recall is null unless the universe was exact, and it measures
// the RESOLVED query, not your intent.
"attribution": [ // source, not citation; url/quote RESERVED, always null in v1
{"source_corpus": "gtm_index", "domain": null, "as_of": "2026-08-12T19:52:31Z", "url": null, "quote": null}
],
"degradation": [ // [] means "checked, nothing degraded" — NEVER "unknown"
{"reason": "vector_coverage_incomplete", "detail": "...", "impact": "recall", "source_corpus": "gtm_index"}
],
"columns": null, // or [{name, description, source}] when a run carries extra columns;
// each column's value sits on every row keyed by `name`. null = plain entity table.
"meta": {
"request_id": "fc8735204043c339af9e9cbffaff2b21", // also X-Request-Id header; always log
"environment": "live", // or "test"
"as_of": "2026-08-12T19:52:31Z",
"credential": "api_key", // or "session" (dashboard playground)
"credits_charged": 0.5 // what THIS request cost, to at most 3 decimals. 0 = it cost nothing: a search that found
// nothing, rows already paid for (a cursor sent again, or the same Idempotency-Key on the
// same UTC day), or a test key. null on a free route
}
}Envelope fields are never omitted (additive-only contract). Hit fields vary by category; only id, category, score are guaranteed on every search hit. id is the domain for companies that have one, else a 16-hex one-way hash. Never send id to /v1/entities/*: chain a company by its domain (by: "domain") and a person by its linkedin_url (by: "li_url").
degradation[].reason — the closed set of twelve: vector_coverage_incomplete, lexical_scoring_unavailable, corpus_unavailable, corpus_timeout, field_not_indexed, mode_downgraded, billing_bypassed (work done, nothing charged — a test key, or a deployment configured to run on an empty wallet), pagination_truncated (a full page with no next cursor while more matches exist — do not treat has_more: false as "complete" when present), axis_not_applicable (part of the question names something this corpus does not carry; the answer is real but wider), anchor_list_capped (more anchors named than the question shape holds — a relationship is pairwise), planner_step_incomplete (one interpretation step did not finish, so its axis was not applied), upstream_advisory (an unclassifiable upstream warning — you cannot branch on it; detail carries its own words).
EntityHit (entities routes)
{"id": "adyen.com", "category": "company", "found": true,
"matches": [ /* records; typed for companies/persons */ ]}
// `matches` is ALWAYS A LIST and [] on a miss. An id lookup resolves to exactly one, so matches[0]
// is the record; `by: "name"` can resolve to several, and reading matches[0] there drops the rest.
// Misses come back found:false — NEVER omitted. You are billed 1 credit per record FOUND; misses are free.
// Entities responses never paginate: has_more false, next_cursor null, total exact, resolved_search null.Coverage / credits / usage
// GET /v1/coverage
{"segments": [{"name": "...", "category": "company", "total": 43605942, "with_embeddings": 13000000,
"with_domain": null, "freshest": null, "stalest": null}],
"not_in_index": ["private companies with no web or LinkedIn presence"],
"as_of": "...", "degradation": [], "meta": {}}
// with_embeddings null = not measured; 0 = no vectors. People are 0 today (no semantic ranking for people).
// The `affiliations` segment counts employment/education EDGES (about five per person), not people, and
// is filed under category "person": skip it when summing person totals.
// freshest/stalest are always null today. Reference: https://dashboard.sproutsdata.ai/docs/api-reference/coverage
// GET /v1/credits — spendable is the ONLY number that predicts a charge; never add allocated+shared
{"platform": "dsp", "scope": "platform",
"allocated": {"granted": 1000, "remaining": 993.3, "reserved": 0, "expires_at": "..."},
"shared": {"remaining": 624}, // reported, NOT spendable on this platform
"spendable": 993.3, "as_of": "...", "meta": {}} // credit figures are numbers to 3 decimals
// scope "tenant_wide_fallback": allocated/shared null, only spendable meaningful
// GET /v1/keys/usage
{"key_id": "ak_...", "period_from": "...", "period_to": "...", "granularity": "day",
"total_requests": 412, "total_credits": 268.415,
"buckets": [{"period_start": "...", "action_type": "dsp.search.fast", "requests": 31, "credits": 15.5}],
"meta": {}}Errors#
Shape (RFC 9457, application/problem+json): type, title, status, code, detail, instance, request_id, plus extra only when it has content. Fields with no value are omitted. type is https://dashboard.sproutsdata.ai/errors/<slug> on every deployment (slug = code with . and _ turned into -); it is documentation, not something to branch on.
{"type": "https://dashboard.sproutsdata.ai/errors/credits-insufficient", "title": "Insufficient credits",
"status": 402, "code": "credits.insufficient", "detail": "This action costs 0.595 credits; 0.25 available.",
"instance": "/v1/search", "request_id": "bd22794d0c5c06f1d3e7045e3c0e32ab",
"extra": {"available": 0.25, "required": 0.595, "action_type": "dsp.search.auto"}}| Code | Status | Retry? |
|---|---|---|
auth.unauthorized | 401 | No — send a valid X-API-Key (generic: covers every pre-auth failure, including a key sent as Authorization: Bearer) |
auth.missing_key | 401 | No (reserved; generic 401 used today) |
auth.invalid_key | 401 | No — key unknown/revoked/expired/wrong prefix |
auth.expired | 401 | No (reserved; reported via invalid_key today) |
auth.insufficient_scope | 403 | No — key lacks the scope; detail lists every scope the route accepts |
auth.unavailable | 503 | Yes, shortly — nothing ran |
auth.browser_origin | 403 | No — move the key server-side |
auth.invalid_credentials | 401 | No (dashboard login only) |
auth.account_exists | 409 | No (dashboard signup only) |
auth.session_required | 401 | No (dashboard surfaces only) |
auth.csrf | 403 | Yes, after refetching the CSRF token (session only) |
auth.mfa_required | — | n/a (login flow branch, not an API error today) |
request.invalid | 422 / 400 / 404 | No — fix request; details in extra.errors[]. 400 on POST /v1/agents/runs with no Idempotency-Key or one over 255 characters; 404 for an agent run id that doesn't exist or is another workspace's |
request.too_large | 413 | No (reserved) |
request.unsupported | 415 | No — send application/json. Also the retrieval engine's "not a question this corpus can answer": extra.try_instead names the endpoint that does |
relationship.anchor_ambiguous | 422 | No — a company name matched several records. extra.anchor + extra.candidates; pick one, or anchor by domain |
feedback.subject_not_found | 404 | No — unknown/expired/not-yours request_id or run_id; one code for all three, deliberately |
feedback.unavailable | 503 | Yes, shortly — nothing was filed |
history.not_found | 404 | No — no such run in your usage history (unknown, or not yours: one code for both) |
lists.not_found | 404 | No — no such list in this workspace (missing, deleted, or another workspace's) |
lists.item_cap_reached | 409 | No — the free workspace's saved-items cap; extra {cap, saved, adding} says how many fit |
lists.unavailable | 503 | Yes, shortly — the list store is unreachable |
export.not_enabled | 404 | No — CSV export is not switched on yet |
pagination.cursor_invalid | 422 | No — restart from page 1 (tampered cursor, or the request changed mid-walk — sort included) |
pagination.cursor_expired | 422 | No — cursor >24h old; re-run the search |
pagination.depth_exceeded | 422 | No — cannot page past 10,000; narrow the search |
idempotency.in_flight | 409 | Wait (reserved) |
idempotency.fingerprint_mismatch | 409 | No (reserved) |
quota.burst_exceeded | 429 | Yes — after Retry-After seconds. Sent by the dashboard playground and the MCP server, not by REST today |
quota.daily_exhausted | 429 | Yes — after window reset (reserved) |
quota.monthly_exhausted | 429 | Yes — after window reset (reserved) |
quota.concurrency_exceeded | 429 | Yes — after Retry-After (30s). POST /v1/agents/runs only: more than 5 runs being started at once on one credential |
quota.entity_ceiling | 429 | No — POST /v1/agents/runs only: the credential was shown 5,000,000 distinct entities in 30 days; extra {seen, limit}; contact us to raise it |
credits.insufficient | 402 | Never — buy credits (POST /v1/billing/top-up/intents gives a person a link to pay); 402 is not rate limiting, no Retry-After |
credits.unavailable | 503 | Yes, with the SAME Idempotency-Key — the billing service could not confirm the charge, so it MAY have landed; a same-key retry that returns the same rows is not charged again |
credits.unknown_action | 500 | No — server misconfiguration; report request_id |
payment.invalid_amount / .declined / .no_method / .forbidden / .unavailable | 422 / 402 / 409 / 403 / 503 | Dashboard billing routes only — unreachable with an API key. payment.declined is the card being refused, never "out of credits" |
payment.topup_not_found | 404 | No — the top-up link expired or is not yours; start a new one with POST /v1/billing/top-up/intents |
upstream.unavailable | 503 / 502 | Yes, after Retry-After — not charged. 502 on agent-run routes: the agent engine answered with an error |
upstream.timeout | 504 | Yes, after Retry-After — not charged |
not_implemented | 501 | No — capability not built here (an unwired category, org chart not available in this environment) |
internal | 500 | Once, then report request_id |
Rules: 402 ≠ 429 (buying vs waiting). Errors are returned before the charge, so an error response is not charged — except 503 credits.unavailable, which MAY have been. A metered POST that failed on YOUR side (timeout, dropped connection) may also have been charged: retry either only with the same Idempotency-Key. Non-application errors use type: https://dashboard.sproutsdata.ai/errors/http-<status> (e.g. http-404 for an unknown path). Codes only the dashboard uses (journey.*, cohort.*, referral.*) are not listed; the full table is at https://dashboard.sproutsdata.ai/docs/guides/errors/codes.
Rate limits#
- There is no per-key request-rate limit on the REST API today, for live or test keys. Build your client to honour a
429withRetry-Afteranyway (wait the header's seconds, then retry): limits may be introduced, and they will be announced in the changelog first. - Limits that do exist: agent-run starts (
quota.concurrency_exceeded,quota.entity_ceiling, above); the dashboard playground for signed-in sessions (30 requests a minute, 500 a day,quota.burst_exceeded); and the hosted MCP server (60 tool calls a minute per credential,quota.burst_exceeded).
Pagination rules#
- Cursor routes:
POST /v1/search,/v1/similar,/v1/relationships,/v1/relationships/reach,/v1/relationships/ask(itscompareandfan_outanswers return a null cursor),GET /v1/agents/runs/{run_id}/results,GET /v1/usage/historyandGET /v1/lists/{list_id}/items(on GET routescursoris a query parameter). They take no page/offset parameter; one in a POST body is a 422. Elsewhere: filter values page withpage,GET /v1/feedbackwithoffset,GET /v1/agents/runstakeslimitonly, and the nine entity routes and/relationships/institutionsreturn a single page. has_more == (next_cursor != null)always. Loop on either.- To page: resend the SAME body plus
cursor. Everything exceptcursorandlimitis fingerprint-frozen; changing anything else =>422 pagination.cursor_invalid.limitmay change between pages, except on/v1/relationships/reach, whose cursor is bound to itslimit. - Cursors are opaque and signed — never construct or edit one.
- Expiry 24h (
pagination.cursor_expired); depth cap 10,000 results (pagination.depth_exceeded). Recovery for all cursor errors: re-run from page 1. - Cursor pages of an
autosearch do not re-run the LLM and keep page 1's ordering./v1/similarpages re-resolve their anchors. - If
degradation[]containspagination_truncated,has_more: falsedoes NOT mean you have everything. - Paging is charged per result delivered across the whole search (see Pricing). Re-sending a cursor already used costs 0; page 1 has no cursor, so keep it.
Canonical curl examples#
# 1. Natural-language company search
curl -X POST https://api.sproutsdata.ai/v1/search \
-H "X-API-Key: $SPROUTS_API_KEY" -H "Content-Type: application/json" \
-d '{"query": "industrial automation companies in Germany", "mode": "auto", "category": "company", "limit": 10}'
# 2. Pure filter search (no LLM, exact total)
curl -X POST https://api.sproutsdata.ai/v1/search \
-H "X-API-Key: $SPROUTS_API_KEY" -H "Content-Type: application/json" \
-d '{"category": "company", "filters": {"industry": {"include": ["Software & Technology"]}, "employee_count": {"gte": 500, "lte": 5000}}, "limit": 10}'
# 3. Page 2 — same body + cursor from page 1's next_cursor
curl -X POST https://api.sproutsdata.ai/v1/search \
-H "X-API-Key: $SPROUTS_API_KEY" -H "Content-Type: application/json" \
-d '{"query": "industrial automation companies in Germany", "mode": "auto", "category": "company", "limit": 10, "cursor": "eyJmIjoiMDNjMjUzNzk3MjM3MTI3ZSIsIm8iOjEs..."}'
# 4. Look-alike companies from two anchors
curl -X POST https://api.sproutsdata.ai/v1/similar \
-H "X-API-Key: $SPROUTS_API_KEY" -H "Content-Type: application/json" \
-d '{"anchors": [{"value": "adyen.com", "by": "domain"}, {"value": "checkout.com", "by": "domain"}], "combine": "and", "limit": 10}'
# 5. Full company records by domain
curl -X POST https://api.sproutsdata.ai/v1/entities/companies \
-H "X-API-Key: $SPROUTS_API_KEY" -H "Content-Type: application/json" \
-d '{"ids": ["adyen.com", "checkout.com"], "by": "domain", "fields": ["account_name", "industry", "employee_count"]}'
# 6. Explicit ordering (needs an explicit category)
curl -X POST https://api.sproutsdata.ai/v1/search \
-H "X-API-Key: $SPROUTS_API_KEY" -H "Content-Type: application/json" \
-d '{"category": "company", "filters": {"industry": {"include": ["Software & Technology"]}}, "sort": [{"field": "employee_count", "direction": "desc"}], "limit": 10}'
# 7. A relationship question in words — the server picks the route
curl -X POST https://api.sproutsdata.ai/v1/relationships/ask \
-H "X-API-Key: $SPROUTS_API_KEY" -H "Content-Type: application/json" \
-d '{"query": "who at Adyen do we know from Stripe", "limit": 10}'
# 8. One company's people by department and seniority
curl -X POST https://api.sproutsdata.ai/v1/org-chart \
-H "X-API-Key: $SPROUTS_API_KEY" -H "Content-Type: application/json" \
-d '{"domain": "adyen.com"}'
# 9. Vocabulary, balance and coverage (all free)
curl https://api.sproutsdata.ai/v1/sorts/company -H "X-API-Key: $SPROUTS_API_KEY"
curl "https://api.sproutsdata.ai/v1/search/filters/industry?level=parents" -H "X-API-Key: $SPROUTS_API_KEY"
curl https://api.sproutsdata.ai/v1/credits -H "X-API-Key: $SPROUTS_API_KEY"
curl https://api.sproutsdata.ai/v1/coverage -H "X-API-Key: $SPROUTS_API_KEY"Operational notes#
- Latency is seconds, not milliseconds:
fastcompany search ≈ 3s,auto≈ 6s (an LLM call; budget for the tail). Cursor pages ≈ 1–2s. autoreturning 0 results can be correct behavior: inspectresolved_search.filters— inferred filters ANDed together can over-constrain. Drop one and retry.- Charge order: validate → cursor → retrieve → build the response → charge. Errors are not charged, except a 503
credits.unavailable, which may have been; an upstream 503/504 costs nothing. - Gate category choice on
GET /v1/coverageat runtime; do not hard-code the nine-category list (and skip theaffiliationssegment when counting people). Do the same for sort fields (GET /v1/sorts/{category}) and filter values (GET /v1/search/filters/…) — all three are free, and all three are vocabularies that move. - Pick the relationship route by the shape of the question: two named companies →
/v1/relationships; one named person →/reach; "what do they have in common" →/institutions; unsure →/ask, which decides and tells you. "Who works at X" is a person search, not a relationship question. - Never read an
/askanswer without readingrouteandreading.warnings[]first. A wrong kind of answer is complete, plausible and about something else. - Always log
meta.request_id/ theX-Request-Idheader. You may send your ownX-Request-Id(≤128 chars of[A-Za-z0-9_.:-]); it is echoed back. meta.credentialisapi_keyfor a key andsessionfor the dashboard playground or an OAuth-authenticated MCP client. Only the first carries an integration compatibility guarantee.