Endpoints

Org chart

Count one company's people by department and seniority, break a department into its sub-functions, and see where they sit.

POST/v1/org-chart

Count a company's people by department and seniority

How one company's people distribute across departments and seniorities.

This is a matrix, not a tree. The corpus holds no reporting lines — no person document carries a manager — so there is no hierarchy here to walk and no direct_reports coming later. What you get is every person we hold for the company, grouped by two closed vocabularies: the 23-function department taxonomy across one axis and the seven-level seniority ladder across the other.

Read total as the headcount and cells as the classified part of it. The two do not match, and the gap is not a bug: someone whose title we hold but whose function we could not infer is counted in total, in neither axis total, and in no cell. Send include_others: true to see those people as a row and a column of their own rather than only in the difference.

Order is fixed rather than reported: departments descend by headcount with the leftover buckets last, and seniorities always run C-Suite to In Training. Two companies' charts therefore read the same way, and a column that is missing is a column with nobody in it.

Narrow with any of the filters and the counts change rather than the display — country: ["Netherlands"] returns the Dutch org chart, not the global one with cells hidden. POST /v1/org-chart/locations is where the place names come from.

To see the people behind a cell, search for them: POST /v1/search with category: "person", include_domains: ["<domain>"] and department / seniority filters set to that cell's labels. The one exception is a cell labelled Others, which is not a value the search filters accept — it names the absence of a value, and there is nothing to filter on.

Scope: dsp.search.read

Use it when you want the shape of a company rather than a list of its people: where the headcount sits, which functions are large, how senior each one is. Outcome: a department × seniority grid with totals on both axes, and two drill-downs:

All three routes are metered under one action, dsp.org_chart, at 1 credit flat per call that returns data. A call that returns nothing — no cells, no departments, no locations — is free and reports credits_charged: 0. A failed call is not charged.

Request#

cityarray of string, ≤ 50

As country. __unknown__ matches an absent city.

countryarray of string, ≤ 50

Case-insensitive; OR within the level and AND across levels, so country=[Netherlands] with city=[Amsterdam] means both. Send __unknown__ to match people with no country recorded. The values come from POST /v1/org-chart/locations.

domainstring | null

The company's domain. A pasted URL is accepted and reduced to its host, so https://www.stripe.com/about and stripe.com are the same request. Give this or linkedin_url, not both. ≤ 300 chars.

employment_typearray of object

How the person is engaged. Orthogonal to seniority.

governance_levelarray of object

Founders, owners, board members and advisors. Orthogonal to seniority rather than a level of it.

has_direct_access_to_c_suiteboolean | null

Tri-state. true keeps only people the corpus marks as having direct access, false only those it marks as not, and omitting it filters on neither — the three answer different questions, because people with no value recorded match neither true nor false.

include_othersbooleandefault: false

Count the people the upstream could not classify onto an axis. Off by default: an Others row is usually large, always undrillable, and would otherwise lead the chart.

linkedin_urlstring | null

The company's LinkedIn page, e.g. linkedin.com/company/stripe. Use it when the record carries no domain, which is common. A personal profile or a /school/ page is refused: neither is a company with an org chart. ≤ 300 chars.

statearray of string, ≤ 50

As country. __unknown__ matches an absent state.

Headers

Idempotency-Keystring

Makes a retry safe. Send a unique value per request and reuse it when you retry that same request: with the same body and credential, on the same UTC day, rows already charged are not charged again.

Give a domain or a linkedin_url, exactly one. Both are refused, as is neither: the two can name different companies, and picking a winner would answer a question you did not ask.

  • A pasted URL is reduced to its host on either field, so https://www.adyen.com/about and adyen.com are the same request. The response echoes the normalized form.
  • A company name is a 422. Find the company with search (category: "company") first.

Filters narrow the population before the grouping, so they change the counts rather than hiding rows: country: ["Netherlands"] returns the Dutch org chart, not the global one with cells blanked.

Response#

attributionarray of Attribution
as_ofstring | null
domainstring | null
quotestring | null

RESERVED for per-fact citation. Always null in v1.

source_corpusSourceCorpus

Where content came from. Closed set — it appears in attribution[]. The first four are the GTM entity graph. The rest are the document corpora behind the non-entity categories, each a distinct index with its own refresh cadence — which is the reason they are named separately rather than lumped under one label: attribution[].as_of means nothing if two corpora updated a month apart share a name.

urlstring | null

RESERVED for per-fact citation. Always null in v1.

cellsarray of OrgChartCell
countinteger

≥ 0.

departmentstring
senioritystring
companyOrgChartCompany

Which company answered, echoed as the identifier that resolved it. Both keys are always present and exactly one is set — whichever the request carried. The domain is echoed normalized, so a pasted URL comes back as the host the counts are actually for, and it is null on the LinkedIn path because a company reached that way generally has no domain at all. The internal id used upstream is deliberately not here.

domainstring | null
linkedin_urlstring | null
degradationarray of Degradation
detailstring
impact"recall" | "precision" | "freshness" | "fields"
reasonDegradationReason

Why an answer is less than it should be. Closed, because a client that cannot branch on the reason can only show the user a shrug. These are the honest constraints — partial vector coverage, smry mapped as keyword so it cannot be scored lexically, a federated corpus being unreachable — surfaced at runtime instead of in a footnote.

source_corpusSourceCorpus | null
department_totalsDepartment Totals

Per-row totals. Not the sum of that row's cells: someone with a department and no seniority is counted here and in no cell.

departmentsarray of string

Row order: headcount descending, with the leftover buckets (Others, General) last however large they are.

metaResponseMeta

Present on every 2xx, including the ones that are not paginated.

as_ofstring
credential"api_key" | "session"default: api_key

Which credential produced this response. session means the dashboard playground ran it as the signed-in user rather than as an API key — so it is billed to the user, subject to interactive rate limits, and carries no integration compatibility guarantee. Additive and defaulted, so it breaks no existing client.

credits_chargednumber | null

What this request cost, in credits, to at most three decimals. 0 when it cost nothing: a Basic or AI search that found nothing, rows this search already paid for (a cursor sent again, or a request repeated with the same Idempotency-Key on the same UTC day), or a test key. A request without a cursor or a repeated key is a new search, so keep the first page rather than asking for it again. null on a free endpoint.

environment"live" | "test"

Echoed on every response so a developer can never mistake a test-key result for a live one.

request_idstring
senioritiesarray of string

Column order: C-Suite, VP, Director / Head, Manager, IC, Entry, In Training, then Others. A fixed ladder, not a frequency order, so two companies' charts read the same way.

seniority_totalsSeniority Totals

Per-column totals, with the same caveat as department_totals.

totalinteger

Everyone at the company under the filters. sum(cells.count) is not the headcount and can differ either way: people the corpus could not place on either axis are counted here and appear in no cell, while a person with several departments or seniorities is counted once here and in each of their cells. Read this as the headcount and the cells as the classified breakdown. ≥ 0.

total is not the sum of the cells

total is everyone at the company under the filters, and the sum of the cells can be lower or higher than it. Lower, because a person whose title we hold but whose function we could not infer is counted in total, in neither axis total, and in no cell. Higher, because a person with several departments or seniorities is counted once in total and once in each of their cells.

Send include_others: true to see those people as a row and a column of their own rather than only in the difference.

Order is fixed, not reported

  • Departments descend by headcount, with the leftover buckets last however large they are.
  • Seniorities always run C-Suite, VP, Director / Head, Manager, IC, Entry, In Training, then Others.

Two companies' charts therefore read the same way, and a column that is missing is a column with nobody in it. Cells are sparse: a pair with no people is absent, not a zero.

Getting the people behind a cell

There is no people list on this route. A cell's two labels are search filters, so send them to POST /v1/search:

{
  "category": "person",
  "include_domains": ["adyen.com"],
  "filters": {
    "department": { "include": ["Engineering / Software"] },
    "seniority": { "include": ["VP"] }
  },
  "limit": 10
}

For a company reached by linkedin_url, scope the search with the company filter instead, since include_domains cannot narrow a company that has no domain:

{
  "category": "person",
  "filters": {
    "company": {
      "include": [{ "value": "linkedin.com/company/procentia", "by": "li" }]
    },
    "department": { "include": ["Sales"] },
    "seniority": { "include": ["VP"] }
  },
  "limit": 10
}

Either way it is a separate, separately-billed call.

Sub-departments#

curl -X POST "https://api.sproutsdata.ai/v1/org-chart/departments" \
  -H "X-API-Key: $SPROUTS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "domain": "adyen.com"
     }'
cityarray of string, ≤ 50

As country. __unknown__ matches an absent city.

countryarray of string, ≤ 50

Case-insensitive; OR within the level and AND across levels, so country=[Netherlands] with city=[Amsterdam] means both. Send __unknown__ to match people with no country recorded. The values come from POST /v1/org-chart/locations.

domainstring | null

The company's domain. A pasted URL is accepted and reduced to its host, so https://www.stripe.com/about and stripe.com are the same request. Give this or linkedin_url, not both. ≤ 300 chars.

employment_typearray of object

How the person is engaged. Orthogonal to seniority.

governance_levelarray of object

Founders, owners, board members and advisors. Orthogonal to seniority rather than a level of it.

has_direct_access_to_c_suiteboolean | null

Tri-state. true keeps only people the corpus marks as having direct access, false only those it marks as not, and omitting it filters on neither — the three answer different questions, because people with no value recorded match neither true nor false.

linkedin_urlstring | null

The company's LinkedIn page, e.g. linkedin.com/company/stripe. Use it when the record carries no domain, which is common. A personal profile or a /school/ page is refused: neither is a company with an org chart. ≤ 300 chars.

statearray of string, ≤ 50

As country. __unknown__ matches an absent state.

Headers

Idempotency-Keystring

Makes a retry safe. Send a unique value per request and reuse it when you retry that same request: with the same body and credential, on the same UTC day, rows already charged are not charged again.

attributionarray of Attribution
as_ofstring | null
domainstring | null
quotestring | null

RESERVED for per-fact citation. Always null in v1.

source_corpusSourceCorpus

Where content came from. Closed set — it appears in attribution[]. The first four are the GTM entity graph. The rest are the document corpora behind the non-entity categories, each a distinct index with its own refresh cadence — which is the reason they are named separately rather than lumped under one label: attribution[].as_of means nothing if two corpora updated a month apart share a name.

urlstring | null

RESERVED for per-fact citation. Always null in v1.

companyOrgChartCompany

Which company answered, echoed as the identifier that resolved it. Both keys are always present and exactly one is set — whichever the request carried. The domain is echoed normalized, so a pasted URL comes back as the host the counts are actually for, and it is null on the LinkedIn path because a company reached that way generally has no domain at all. The internal id used upstream is deliberately not here.

domainstring | null
linkedin_urlstring | null
degradationarray of Degradation
detailstring
impact"recall" | "precision" | "freshness" | "fields"
reasonDegradationReason

Why an answer is less than it should be. Closed, because a client that cannot branch on the reason can only show the user a shrug. These are the honest constraints — partial vector coverage, smry mapped as keyword so it cannot be scored lexically, a federated corpus being unreachable — surfaced at runtime instead of in a footnote.

source_corpusSourceCorpus | null
departmentsarray of OrgChartDepartment

Departments with people, headcount descending. A department whose name is absent from POST /v1/org-chart's departments can appear here: that route relabels onto a closed taxonomy and this one reports what is stored.

headcountinteger

≥ 0.

namestring
sub_departmentsarray of OrgChartSubDepartment
headcountinteger

≥ 0.

namestring

The sub-function's name, or Uncategorized for people in the department with no sub-function recorded.

seniority_countsSeniority Counts

Counts by seniority within this sub-department. Keys are the upstream's raw labels, so Uncategorized appears here where the grid says Others — the two routes aggregate different fields and their vocabularies are not merged.

metaResponseMeta

Present on every 2xx, including the ones that are not paginated.

as_ofstring
credential"api_key" | "session"default: api_key

Which credential produced this response. session means the dashboard playground ran it as the signed-in user rather than as an API key — so it is billed to the user, subject to interactive rate limits, and carries no integration compatibility guarantee. Additive and defaulted, so it breaks no existing client.

credits_chargednumber | null

What this request cost, in credits, to at most three decimals. 0 when it cost nothing: a Basic or AI search that found nothing, rows this search already paid for (a cursor sent again, or a request repeated with the same Idempotency-Key on the same UTC day), or a test key. A request without a cursor or a repeated key is a new search, so keep the first page rather than asking for it again. null on a free endpoint.

environment"live" | "test"

Echoed on every response so a developer can never mistake a test-key result for a live one.

request_idstring

The same population and filters as the grid, grouped one level finer. Two differences will look like inconsistencies if you expect otherwise:

  • Labels are the corpus's raw values rather than the grid's relabelled taxonomy, so a department name here can be absent from the grid's departments.
  • People with no sub-function are named Uncategorized where the grid says Others. Two routes aggregating two fields; their vocabularies are deliberately not merged, because renaming an upstream's labels is how two systems start disagreeing about counts.

Locations#

curl -X POST "https://api.sproutsdata.ai/v1/org-chart/locations" \
  -H "X-API-Key: $SPROUTS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "domain": "adyen.com",
       "search": "amst"
     }'
domainstring | null

The company's domain. A pasted URL is accepted and reduced to its host, so https://www.stripe.com/about and stripe.com are the same request. Give this or linkedin_url, not both. ≤ 300 chars.

linkedin_urlstring | null

The company's LinkedIn page, e.g. linkedin.com/company/stripe. Use it when the record carries no domain, which is common. A personal profile or a /school/ page is refused: neither is a company with an org chart. ≤ 300 chars.

searchstring | null

Case-insensitive substring over all three levels, for typeahead. It filters *people*, not labels, so a match on a city keeps that city's country and state in the tree with counts reduced to the matching people. ≥ 1 chars, ≤ 100 chars.

Headers

Idempotency-Keystring

Makes a retry safe. Send a unique value per request and reuse it when you retry that same request: with the same body and credential, on the same UTC day, rows already charged are not charged again.

attributionarray of Attribution
as_ofstring | null
domainstring | null
quotestring | null

RESERVED for per-fact citation. Always null in v1.

source_corpusSourceCorpus

Where content came from. Closed set — it appears in attribution[]. The first four are the GTM entity graph. The rest are the document corpora behind the non-entity categories, each a distinct index with its own refresh cadence — which is the reason they are named separately rather than lumped under one label: attribution[].as_of means nothing if two corpora updated a month apart share a name.

urlstring | null

RESERVED for per-fact citation. Always null in v1.

companyOrgChartCompany

Which company answered, echoed as the identifier that resolved it. Both keys are always present and exactly one is set — whichever the request carried. The domain is echoed normalized, so a pasted URL comes back as the host the counts are actually for, and it is null on the LinkedIn path because a company reached that way generally has no domain at all. The internal id used upstream is deliberately not here.

domainstring | null
linkedin_urlstring | null
degradationarray of Degradation
detailstring
impact"recall" | "precision" | "freshness" | "fields"
reasonDegradationReason

Why an answer is less than it should be. Closed, because a client that cannot branch on the reason can only show the user a shrug. These are the honest constraints — partial vector coverage, smry mapped as keyword so it cannot be scored lexically, a federated corpus being unreachable — surfaced at runtime instead of in a footnote.

source_corpusSourceCorpus | null
locationsarray of OrgChartCountry

Countries with people, in the upstream's order. Labels are the corpus's own casing; __unknown__ names the people with no value at that level, and is accepted back as a filter.

countinteger

≥ 0.

countrystring
statesarray of OrgChartState
citiesarray of OrgChartCity
citystring
countinteger

≥ 0.

countinteger

≥ 0.

statestring
metaResponseMeta

Present on every 2xx, including the ones that are not paginated.

as_ofstring
credential"api_key" | "session"default: api_key

Which credential produced this response. session means the dashboard playground ran it as the signed-in user rather than as an API key — so it is billed to the user, subject to interactive rate limits, and carries no integration compatibility guarantee. Additive and defaulted, so it breaks no existing client.

credits_chargednumber | null

What this request cost, in credits, to at most three decimals. 0 when it cost nothing: a Basic or AI search that found nothing, rows this search already paid for (a cursor sent again, or a request repeated with the same Idempotency-Key on the same UTC day), or a test key. A request without a cursor or a repeated key is a new search, so keep the first page rather than asking for it again. null on a free endpoint.

environment"live" | "test"

Echoed on every response so a developer can never mistake a test-key result for a live one.

request_idstring

Two uses:

  • On its own, it is the geographic shape of the company.
  • Beside the grid, it is that route's vocabulary: these are the exact strings its country, state and city filters accept, so there is no need to guess at place names.

A parent's count is not the sum of its children: someone with a country and no city is counted at the country and in no city beneath it. Each level answers "how many people do we place at least this precisely".

search filters people rather than labels. Matching a city keeps that city's country and state in the tree, with counts reduced to the matching people, so the branch stays navigable instead of returning a city with no parent.

A level with no value recorded is labelled __unknown__, and that string is accepted back as a filter value on the grid.

Errors#

StatusCodeSummary
401auth.unauthorizedNo usable credential (cause deliberately unspecified)
402credits.insufficientThe allocation cannot fund this action
403auth.browser_originA secret key was used from a browser
403auth.insufficient_scopeThe credential does not hold the required scope
422pagination.cursor_invalidThe cursor could not be read (e.g. a filter changed mid-pagination)
422request.invalidA field failed validation (extra.errors has the detail)
429quota.burst_exceededInteractive burst limit hit — wait Retry-After seconds
501not_implementedA capability that is not available yet
503credits.unavailableThe billing service could not confirm the charge; retry with the same Idempotency-Key
503upstream.unavailableThe retrieval engine is unreachable (not charged)
504upstream.timeoutThe retrieval engine did not answer in time (not charged)

Two worth handling distinctly:

  • An unknown company is a 422 request.invalid, not a 404. The detail names the identifier you sent, because correcting it is your fix. On the linkedin_url path this is raised before any charge, since resolution happens first.
  • 501 not_implemented means org charts are not enabled in this deployment. It is never charged, and retrying will not change it.