API

Search

Search is the most complex surface Curviate exposes, and almost all of that complexity is one idea: LinkedIn does not filter on words, it filters on identifiers. This page teaches the model behind the parameters, so that when a call comes back empty or refuses to run you already know which of the two things happened and what the next call is.

Before you start

You need two things: an API key, and at least one connected LinkedIn account whose acc_ id you have. Every search route is account scoped, so without an account id there is no search to make. Get the key from the key chip in the dashboard top bar and the account id from GET /v1/accounts; if you have not connected an account yet, start with

Authentication and accounts

. Everything on this page runs against the core search surface, which every connected account has. Sales Navigator and Recruiter search are separate surfaces with their own entitlement, covered under "Tier entitlement and quota" below.

The examples use two shell variables so you can paste them as written:

export CURVIATE_API_KEY=cvt_live_YOUR_API_KEY
export ACCOUNT_ID=acc_YOUR_ACCOUNT_ID

The model in one paragraph

A search has three moving parts. A surface decides which index you are querying and which filter vocabulary applies (core people search, company search, job search, and so on). Filters narrow that index, and they come in two kinds that behave completely differently: free-text filters take the words you type, and id-bearing filters take an opaque identifier and nothing else. The taxonomy is LinkedIn's own controlled list of those identifiers, one list per filter category, and it is the thing you resolve against before you can filter. Almost every surprise in search traces back to treating an id-bearing filter as if it were a free-text one.

How filters compose

Fields combine with AND. Values inside one field combine with OR. There is no grouping, no negation, and no operator syntax; the shape of the body is the whole query language.

{
  "keywords": "platform engineer",
  "industry": ["4"],
  "location": ["103035651", "90009712"],
  "network_distance": [1, 2]
}

That reads as: matches the free text platform engineer, AND is in industry 4, AND is in location 103035651 OR 90009712, AND is a 1st OR 2nd degree connection. Adding a field can only ever shrink the result set.

Three kinds of field appear in a people search body:

KindFieldsWhat you pass
Free text

keywords, and the five scoped variants inside advanced_keywords (first_name, last_name, title, company, school)

The words themselves. keywords searches the whole profile; the scoped variants search one field each.

Id bearing

industry, location, current_company, past_company, school, service, connections_of, followers_of

An array of opaque taxonomy ids. A human readable word is resolved for you, or you are told which options to pick from.

Enum or scalar

network_distance, profile_language, open_to_volunteering

Fixed values from a closed set. network_distance takes 1, 2, or 3.

Note which of your terms is really free text

advanced_keywords.company matches a company name string and is free text. current_company filters on a company entity and needs its id. They are not two spellings of one filter, and they do not return the same people.

Taxonomy resolution

An id-bearing filter accepts one thing: the identifier LinkedIn's taxonomy gives that entity. "Software Development" is not that identifier. "4" is.

Resolution has its own endpoint. It takes a category and a human term, and returns the candidate entries with their ids:

curl -sG "https://api.curviate.com/v1/$ACCOUNT_ID/search/parameters" \
  -H "Authorization: Bearer $CURVIATE_API_KEY" \
  --data-urlencode "type=INDUSTRY" \
  --data-urlencode "keywords=Software Development" \
  --data-urlencode "limit=5"
{
  "object": "search_parameter_list",
  "items": [
    { "id": "4", "name": "Software Development" },
    { "id": "3102", "name": "IT System Custom Software Development" }
  ],
  "cursor": null
}

type is required and closed. The accepted values are LOCATION, PEOPLE, RELATION, COMPANY, SCHOOL, INDUSTRY, SERVICE, JOB_FUNCTION, JOB_TITLE, EMPLOYMENT_TYPE, and SKILL. Anything else is rejected before the call runs:

{
  "code": "INVALID_REQUEST",
  "message": "type: Invalid enum value. Expected 'LOCATION' | 'PEOPLE' | 'RELATION' | 'COMPANY' | 'SCHOOL' | 'INDUSTRY' | 'SERVICE' | 'JOB_FUNCTION' | 'JOB_TITLE' | 'EMPLOYMENT_TYPE' | 'SKILL', received 'GROUPS'",
  "retry_hint": null,
  "user_fixable": true,
  "retry_likely_to_succeed": false
}

keywords is required too, for every type. There is no way to enumerate a whole category; you always resolve a term you already have in mind.

Which filter resolves against which type

Filter fieldtypeId shape
industryINDUSTRY

Numeric string, often short: 4

locationLOCATION

Numeric string, nine digits: 103035651

current_company, past_company

COMPANYNumeric string
schoolSCHOOLNumeric string
serviceSERVICENumeric string
connections_ofRELATION

Member id: ACoAA...

followers_ofPEOPLE

Member id: ACoAA...

Why a wrong id is worse than a wrong word

An empty page is never left unexplained

Putting a human readable string in an id-bearing filter does not run a search that quietly finds nobody. The value is resolved before the search runs. If it matches several options you get a 422 naming the offending field and listing the ids to pick from. If it matches no option at all we cannot tell whether you sent a typo or an id we simply do not recognise, so the value is sent on as an id and named in notices on the 200. Either way an empty items array is explained: it means nobody matched, unless a notice tells you one of your filters was never verified.

# location is a human word, not a filter id.
curl -s -X POST "https://api.curviate.com/v1/$ACCOUNT_ID/search/people?limit=3" \
  -H "Authorization: Bearer $CURVIATE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"keywords": "platform engineer", "location": ["Berlin"]}'
{
  "code": "FILTER_CANDIDATES_REQUIRED",
  "message": "These filter values matched more than one option: Berlin.",
  "user_fixable": true,
  "retry_likely_to_succeed": true,
  "unresolved": [
    {
      "field": "location",
      "value": "Berlin",
      "candidates": [
        { "id": "103035651", "name": "Berlin, Germany" },
        { "id": "106967730", "name": "Berlin, Berlin, Germany" },
        { "id": "90009712", "name": "Berlin Metropolitan Area" }
      ]
    }
  ],
  "next_action": "Re-call passing the picked ids for the values listed in unresolved."
}

That is HTTP 422. Re-send the same query with location set to a chosen id, such as ["103035651"], and you get a full page. A value that matches nothing at all, rather than several things, is not an error; see the section below.

FILTER_CANDIDATES_REQUIRED

Every structured search surface resolves id-bearing filter values for you before the search runs: you may pass a plain string such as "Software Development" instead of looking its id up first. When a value cannot be resolved to exactly one entry, the search does not run and FILTER_CANDIDATES_REQUIRED is raised instead, carrying the candidates you can choose from.

This is one error with one payload, on both surfaces. On REST it arrives as HTTP 422 with the body shown above. On the MCP tools, which expose search as tools rather than routes, it arrives as a tool error carrying the same unresolved and next_action fields. Anything below that describes the shape applies to both.

Where the MCP server actually is. It is not on api.curviate.com, the host every other call on this page uses. It is a separate Streamable HTTP MCP endpoint at https://app.curviate.com/api/mcp/v1 (also reachable at the shorter https://app.curviate.com/mcp). Point your MCP client at that URL and authenticate the same way as REST, Authorization: Bearer $CURVIATE_API_KEY. An unauthenticated request to it returns 401 UNAUTHORIZED rather than 404, which is how you can tell you have reached it.

What it means. One or more of your filter values did not map to a single taxonomy entry. The search itself did not run, and no partial result was returned. The attempt is still a request, so it counts against your rate limit like any other; resolving before you call, rather than after a rejection, is what keeps a busy loop cheap.

Why it is raised rather than guessed. Resolution is deterministic and strict: it accepts an exact name match, or an exact match after case folding and whitespace trimming, and nothing else. There is no fuzzy matching and no semantic matching, by design. "Berlin" is not the name of any location entry, so it resolves to zero exact matches out of ten plausible looking ones. Silently picking the first would quietly change the meaning of your query, and returning zero results would hide the problem entirely. So the request is refused and the decision is handed back to you.

What it looks like. This is a real response, captured live. The intent was platform engineers in Berlin working in software development:

{
  "code": "FILTER_CANDIDATES_REQUIRED",
  "message": "These filter values matched more than one option: Berlin.",
  "retry_hint": null,
  "user_fixable": true,
  "retry_likely_to_succeed": true,
  "unresolved": [
    {
      "field": "location",
      "value": "Berlin",
      "candidates": [
        { "id": "103035651", "name": "Berlin, Germany" },
        { "id": "106967730", "name": "Berlin, Berlin, Germany" },
        { "id": "90009712", "name": "Berlin Metropolitan Area" },
        { "id": "105506608", "name": "Berlin, Connecticut, United States" },
        { "id": "107184029", "name": "Berlin, Maryland, United States" },
        { "id": "107096287", "name": "Berlin, New Jersey, United States" },
        { "id": "101301095", "name": "10115, Berlin, Berlin, Germany" },
        { "id": "105606863", "name": "Berlin, New Hampshire, United States" },
        { "id": "104944500", "name": "10178, Berlin, Berlin, Germany" },
        { "id": "110428803", "name": "Berlin, Wisconsin, United States" }
      ]
    }
  ],
  "correlation_id": "mcp_cdd89fd6a3753f77b1402d17",
  "next_action": "One or more filter values matched several options. Re-call passing the picked ids for the values listed in unresolved."
}

Note what did not fail. industry: "Software Development" resolved cleanly to 4 and is absent from unresolved. Only the value that needs a human decision is listed.

The exact next call. Repeat the identical tool call with the offending value replaced by an id you picked from its own candidates array. Everything else stays as it was. These are the tool call arguments; in a chat client you would simply say which of the candidates you meant:

{
  "name": "search_people",
  "arguments": {
    "account_id": "acc_YOUR_ACCOUNT_ID",
    "keywords": "platform engineer",
    "industry": "Software Development",
    "location": "103035651",
    "limit": 3
  }
}

That call returns a result set. An id is recognised as already resolved and passes straight through, so a filter body can mix picked ids and plain strings freely, and re-calling is one substitution rather than a rewrite.

It reports every unresolved value at once

Resolution never stops at the first problem. Every id-bearing value is attempted, and a single error lists all of them, each with its own candidates. Two unhelpful values produce one round trip, not two:

{
  "code": "FILTER_CANDIDATES_REQUIRED",
  "message": "These filter values matched more than one option: AI, Berlin.",
  "unresolved": [
    {
      "field": "industry",
      "value": "AI",
      "candidates": [
        { "id": "94", "name": "Airlines and Aviation" },
        { "id": "2366", "name": "Air, Water, and Waste Program Management" },
        { "id": "404", "name": "Steam and Air-Conditioning Supply" },
        { "id": "398", "name": "Water, Waste, Steam, and Air Conditioning Services" }
      ]
    },
    {
      "field": "location",
      "value": "Berlin",
      "candidates": [
        { "id": "103035651", "name": "Berlin, Germany" },
        { "id": "106967730", "name": "Berlin, Berlin, Germany" },
        { "id": "90009712", "name": "Berlin Metropolitan Area" }
      ]
    }
  ],
  "next_action": "One or more filter values matched several options. Re-call passing the picked ids for the values listed in unresolved."
}

"AI" is the instructive one. Its candidates are airlines and air conditioning, because the taxonomy has no entry abbreviated AI and the lookup fell back to matching those two letters inside longer names. Reading those candidates and picking one would produce a confidently wrong query. The right move when the candidates look absurd is not to pick from them; it is to search again with the full term the taxonomy actually uses, such as Artificial Intelligence. Prefer full terms over abbreviations everywhere in search for the same reason.

Resolution is remembered

A value that resolves cleanly is cached per workspace for up to 24 hours, so a repeated industry: "Software Development" costs no extra lookup on later calls. Values that need a pick are never cached, because the pick is yours, not the resolver's.

When it is a 422 and when it is a notice

Two different things can happen to a filter value we cannot turn into exactly one id, and they are handled differently because we know different things about them.

A value that matched several options is unambiguously your call to make, so it is an error and you pick. A value that matched nothing is not proof you sent something invalid. GET .../search/parameters is a name index: it answers "what is called this", and it cannot answer "is this a valid id". Ids never look like names, so a real id matches nothing there, exactly as a typo does. Rejecting what it does not recognise would reject the very ids the 422 above tells you to re-send. So the value is sent on unchanged and reported instead.

Value matched several optionsValue matched nothing
Status422200
Where it is reported

unresolved[] on the error body

notices[] on the result page

CodeFILTER_CANDIDATES_REQUIREDFILTER_VALUE_UNRESOLVED
It meansPick one of theseWe could not check this, so we sent it as you wrote it
Did the search runNoYes, with your value used as an id
How you fix itRe-send with a chosen id

Nothing, if the results look right. Otherwise look the value up with GET .../search/parameters, or correct the spelling

Both report every offending value in one response rather than stopping at the first, so a single correction round trip is enough. A request carrying one of each returns the 422, and the value that matched nothing rides along in notices[] on that same body, so you still see both problems at once.

There is a second notice code, FILTER_VALUE_UNCHECKED. It appears when a value already looked like an id, so no lookup was attempted, and the page came back empty. A non-existent id returns exactly the same empty page as a search that genuinely has no matches, so the notice tells you which of the two you might be looking at. It never appears on a page that has results.

{
  "object": "people_search_result",
  "items": [],
  "paging": { "total_count": null },
  "cursor": null,
  "notices": [
    {
      "code": "FILTER_VALUE_UNRESOLVED",
      "message": "The location value \"Berlim\" matched no known filter option, so it was sent on as an id we could not check. If these results are not what you expected, look the value up with GET /v1/{account_id}/search/parameters and re-send the id it returns.",
      "field": "location",
      "value": "Berlim"
    }
  ]
}

Worked example, end to end

The intent, in plain language: find platform engineers in Berlin who work in software development. Three of those five terms are id-bearing, so the intent cannot be sent as written. Here is the whole path.

Step 1: turn the words into ids

Two resolution calls, one per id-bearing term.

curl -sG "https://api.curviate.com/v1/$ACCOUNT_ID/search/parameters" \
  -H "Authorization: Bearer $CURVIATE_API_KEY" \
  --data-urlencode "type=INDUSTRY" \
  --data-urlencode "keywords=Software Development" \
  --data-urlencode "limit=5"
{
  "object": "search_parameter_list",
  "items": [
    { "id": "4", "name": "Software Development" },
    { "id": "3102", "name": "IT System Custom Software Development" }
  ],
  "cursor": null
}
curl -sG "https://api.curviate.com/v1/$ACCOUNT_ID/search/parameters" \
  -H "Authorization: Bearer $CURVIATE_API_KEY" \
  --data-urlencode "type=LOCATION" \
  --data-urlencode "keywords=Berlin" \
  --data-urlencode "limit=5"
{
  "object": "search_parameter_list",
  "items": [
    { "id": "103035651", "name": "Berlin, Germany" },
    { "id": "106967730", "name": "Berlin, Berlin, Germany" },
    { "id": "90009712", "name": "Berlin Metropolitan Area" },
    { "id": "105506608", "name": "Berlin, Connecticut, United States" },
    { "id": "107184029", "name": "Berlin, Maryland, United States" }
  ],
  "cursor": "NQ"
}

This is the step that has to be a decision, not a lookup. Berlin, Germany is the city. Berlin Metropolitan Area is wider and will include commuter towns. Berlin, Connecticut, United States is a different continent. Nothing in the API can make this choice for you, which is the whole reason the MCP tools stop and ask instead of guessing. Take the city: 103035651.

The cursor on that response is worth noticing: there were more than five matching locations. When the entry you expect is not in the first page, raise limit (up to 100 on this resolution endpoint, /search/parameters, a different bound from the search endpoint's page size below) or make keywords more specific, rather than assuming it is absent.

Step 2: run the search with the ids

curl -s -X POST "https://api.curviate.com/v1/$ACCOUNT_ID/search/people?limit=3" \
  -H "Authorization: Bearer $CURVIATE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "keywords": "platform engineer",
    "industry": ["4"],
    "location": ["103035651"]
  }'
{
  "object": "people_search_result",
  "items": [
    {
      "id": "ACoAAExamplePersonOne000000000000000000",
      "member_id": "481920367",
      "public_identifier": "jordan-avery-example",
      "full_name": "Jordan Avery",
      "headline": "Platform Engineer, Developer Experience",
      "location": "Berlin",
      "avatar_url": "https://media.example.com/dms/image/v2/EXAMPLE/profile-displayphoto-shrink_100_100/0",
      "profile_picture_url_large": "https://media.example.com/dms/image/v2/EXAMPLE/profile-displayphoto-shrink_800_800/0",
      "profile_url": "https://www.linkedin.com/in/jordan-avery-example",
      "network_distance": "OUT_OF_NETWORK",
      "is_premium": true,
      "visibility": "full"
    },
    {
      "id": "ACoAAExamplePersonTwo000000000000000000",
      "full_name": "LinkedIn Member",
      "headline": "Site Reliability Engineer, Infrastructure Platform",
      "location": "Berlin",
      "avatar_url": "https://media.example.com/dms/image/v2/EXAMPLE/profile-displayphoto-shrink_100_100/1",
      "profile_picture_url_large": "https://media.example.com/dms/image/v2/EXAMPLE/profile-displayphoto-shrink_800_800/1",
      "network_distance": "OUT_OF_NETWORK",
      "visibility": "hidden"
    }
  ],
  "paging": { "total_count": null },
  "cursor": "Mw",
  "notices": [
    {
      "code": "SOME_RESULTS_HIDDEN",
      "message": "1 of the 2 results on this page carry no identity this account can see, so those entries cannot be opened, read back, or contacted. The connected LinkedIn account's own subscription level limits which profiles it is allowed to identify. Read each item's visibility field to tell them apart."
    }
  ]
}

Step 3: read the result set honestly

Three things in that response routinely trip people up.

visibility tells you which results you can actually use, and it is always present. The second item is hidden: it has no member_id, no public_identifier, and no profile_url, and its full_name is the literal placeholder LinkedIn Member. That is a real person LinkedIn did not disclose to the connected account, not a bug and not a deleted profile. The cause is the connected LinkedIn account's own subscription level, which decides which profiles it is allowed to identify, so the same query run from a different account can return a different mix. Branch on visibility, never on the name string, which is a display label that changes with the viewer's language.

Count the full results. total_count is null on people search, so there is no total to read, and the size of a page says nothing about how many of its results you can act on. On an account with limited visibility a page can be entirely hidden, and that is still a 200: it is a truthful answer, not an error. The notices[] array on the response says so directly, with SOME_RESULTS_HIDDEN when part of the page is hidden and ALL_RESULTS_HIDDEN when none of it can be used. A third code, PAGE_TRUNCATED, means the page is short for an unrelated reason: we stopped fetching, not LinkedIn. Branch on the code.

network_distance is a string, not the number you filtered on. You filter with network_distance: [1, 2] and you read back values like OUT_OF_NETWORK. They are not the same vocabulary, and a value of OUT_OF_NETWORK says nothing about whether the result is usable: plenty of out-of-network results are full.

id is the handle for everything downstream, on the full results. Feed it straight into a profile read, a connection request, or a new chat; no second lookup is needed. On a hidden result there is nothing to feed: a profile read against it returns 403 LINKEDIN_FEATURE_NOT_SUBSCRIBED, because the account that ran the search is not entitled to see that person.

Pagination

Pass limit on the query string, between 1 and 50, defaulting to 10. Over 50 is a validation error rather than a silent clamp:

{
  "code": "INVALID_REQUEST",
  "message": "limit: Number must be less than or equal to 50",
  "retry_hint": null,
  "user_fixable": true,
  "retry_likely_to_succeed": false
}

Every response carries a cursor. Send it back as a query parameter, with the same body, to get the next page:

curl -s -X POST "https://api.curviate.com/v1/$ACCOUNT_ID/search/people?limit=3&cursor=Mw" \
  -H "Authorization: Bearer $CURVIATE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "keywords": "platform engineer",
    "industry": ["4"],
    "location": ["103035651"]
  }'

The cursor is opaque. Do not parse it, do not build one, and do not carry one across a change of filters; a cursor only means anything against the query that produced it.

The bounds

BoundValueWhat you see at the edge
Page size1 to 50

INVALID_REQUEST above 50

Matches per queryNot reported

total_count is null; LinkedIn does not say how many people match

End of resultsn/a

cursor comes back null, and nothing else means the end

Upstream pages per request10

a page cut short by this carries a PAGE_TRUNCATED notice

Not knowing the size of the result set is the thing that changes how you design a job. cursor coming back null is the only end signal there is. Walk until it does, and do not read anything into a short or empty page: one can arrive with results still waiting behind the cursor, and when it does it carries a PAGE_TRUNCATED notice saying so. A loop written as while (items.length) stops early on that page and reports no results when there were results.

That page exists because we assemble yours by fetching upstream pages until your limit is filled, and one request fetches at most ten of them. A deep offset spends that budget skipping rather than collecting: offset=200 returns an empty page with a live cursor, and takes several requests to reach the results, most of them returning nothing. Those upstream fetches are also what LinkedIn rate limits, so one deep-offset request can spend the whole per-window allowance and leave the next one with a 429.

So page forward with cursor and treat offset as a way to resume at a position you already reached, not a way to jump to a distant one. A single query also stops serving pages well before it has shown you everyone who matches, so for broad coverage split the query along a filter axis (city by city, industry by industry) rather than paging deeper.

Tier entitlement and quota

There are three separate search surfaces, and having a connected account only entitles you to the first.

SurfaceRoute prefixNeeds
Core search/v1/{account_id}/search/...Any connected account
Sales Navigator search/v1/{account_id}/sales-navigator/search/...A live Sales Navigator subscription on the LinkedIn account itself
Recruiter search/v1/{account_id}/recruiter/search/...A live Recruiter subscription on the LinkedIn account itself

Calling a premium surface from an account without the subscription is a 403. For Recruiter, the message names which surface is missing:

{
  "code": "LINKEDIN_FEATURE_NOT_SUBSCRIBED",
  "message": "This account is missing the Recruiter subscription required for this operation.",
  "retry_hint": null,
  "user_fixable": true,
  "retry_likely_to_succeed": false
}

For Sales Navigator, it does not name the surface. The body is the same code, but a generic message naming the LinkedIn account without naming which product it lacks:

{
  "code": "LINKEDIN_FEATURE_NOT_SUBSCRIBED",
  "message": "The connected LinkedIn account is missing the subscription required for this operation.",
  "retry_hint": null,
  "user_fixable": true,
  "retry_likely_to_succeed": false
}

Use the route you called, not the message, to tell the two apart. Either way, do not retry it. Nothing about the request is wrong, and no backoff will help; the fix is a subscription on the LinkedIn account, or a different account. TIER_NOT_ACTIVE is the neighbouring error with the same HTTP status and a different cause: that one is the Curviate side seat add-on, and it carries a required_tier field naming what to enable. The two codes are the branch point, because the remedies are opposite: TIER_NOT_ACTIVE is settled in your Curviate billing, LINKEDIN_FEATURE_NOT_SUBSCRIBED on LinkedIn itself.

The same LINKEDIN_FEATURE_NOT_SUBSCRIBED code also comes back from a plain core profile read, with no premium surface involved, when the person being read is one the connected account is not entitled to see. Those are the results core search returns with visibility: "hidden", and reading one back is the one case where a 403 here is expected rather than a misconfiguration. Skip them instead of retrying: no request shape makes a hidden person readable from an account that cannot see them.

What search costs you

Search has no separate daily allowance. What it consumes is ordinary request budget, and every authenticated response tells you where you stand:

RateLimit-Policy: "tenant";q=2500;w=60
RateLimit: "tenant";r=2499;t=29

q is the workspace allowance per window, w the window in seconds, r what is left, and t the seconds until reset. Exceeding it is 429 with RATE_LIMIT_TENANT, or RATE_LIMIT_ACCOUNT when a single LinkedIn account is the one running hot. Read the headers and pace yourself rather than discovering the ceiling; the algorithm to do that is on Rate limits.

Two things to budget for that are easy to miss. Resolution calls count. GET .../search/parameters is a request like any other, so a naive loop that re-resolves the same city on every iteration can spend more budget on lookups than on searches. Resolve once and keep the id. A page is a request. Walking a thousand people at limit=50 is twenty calls, plus whatever resolution it took to get there.

Errors you will actually hit

CodeHTTPOn search it usually meansDo this
FILTER_CANDIDATES_REQUIRED422A filter value did not resolve to exactly one entity.

Pick an id from that value's own candidates and re-call. If the candidates look unrelated, retry with the taxonomy's full term instead of an abbreviation.

INVALID_REQUEST400

limit above 50, a type outside the enum, or a missing keywords on a resolution call.

The message names the offending field. Fix it; retrying unchanged never helps.

RESOURCE_NOT_FOUND404

The path did not match a route. On search this is nearly always a wrong path shape rather than a wrong id.

Check the shape before the ids: it is /v1/{account_id}/search/people.

ACCOUNT_NOT_FOUND404The account id is not owned by this API key.

Read your ids from GET /v1/accounts.

LINKEDIN_FEATURE_NOT_SUBSCRIBED403

A Sales Navigator or Recruiter surface on an account without that subscription, or a profile read of a person the connected account is not entitled to see.

Use core search, or use an account that has the subscription. On a profile read, skip results whose visibility is hidden.

TIER_NOT_ACTIVE403The Curviate side add-on for that surface is not enabled.

Enable the tier named in required_tier.

RATE_LIMIT_TENANT429Too many requests in the window, often from paging or resolving.

Honour Retry-After, then back off with jitter.

LINKEDIN_RATE_LIMITED429LinkedIn is throttling the account directly.Back off substantially and slow the whole job, not just this call.

An empty result set is not on this list, because it is not an error. It usually means the query was genuinely narrow. A filter value that matched several options is refused before the search runs, with a 422 that names the field. One that matched nothing is reported in notices[] on the page itself, and so is a page we cut short before it filled. So check notices, and check that cursor is null, before concluding an empty items array means nobody matched.

Next steps

COMPANY · LEGAL

Privacy Policy

Redmer Holding GmbHLast updated August 4, 2026

Who we are

Curviate is operated by Redmer Holding GmbH ("Curviate", "we", "us"), a German GmbH registered at Amtsgericht Bonn, HRB 29957, registered address Hostertstraße 16, 53332 Bornheim, Germany. Full company details are on our Imprint. We haven't appointed a statutory Data Protection Officer, since our processing doesn't reach the scale or sensitivity that requires one. Privacy questions go to privacy@curviate.com.

The two roles we play

When you create an account and use Curviate, we process your own data (identity, billing, API keys, connector authorizations). For that data, we are the controller.

When you use Curviate to act on your own connected LinkedIn account, viewing profiles, sending messages, managing engagement, that content and those contacts belong to that account and its people. You are the controller of that data; we are the processor, acting only on your instructions, under a Data Processing Agreement available on request (see below). If one of your contacts has a question about being reached through Curviate, you're who they should contact first; email privacy@curviate.com if you need help routing it.

What we collect, and why

DataWhy
Account identity (name, email, sign-in method)Create and secure your account
Your LinkedIn credentialsOperate the actions you request
LinkedIn content returned by an API callFulfil that specific request, nothing more
API keys and connector (OAuth) authorizationsAuthenticate your API, CLI, MCP, or SDK requests
Billing detailsCharge you correctly and meet our tax obligations
Usage and security logsKeep the service reliable and abuse-free
Support messagesRespond to you
Website analytics, only if you opt inUnderstand how the site is used

We rely on our contract with you, our legitimate interest in running and securing the service, our legal obligations (tax law, for example), and, for analytics, your consent. We never sell your data or use it to train models.

Where it's processed, and who else touches it

Our infrastructure runs in the EU. Hosting: Railway. Database and auth: Supabase, Ireland. Email: Resend. Payments: Stripe. Network security: a DDoS-protection provider sits in front of our app and never sees or stores request content. LinkedIn connectivity: a third-party infrastructure provider that lets us execute LinkedIn actions on your behalf. Error tracking: Sentry, Frankfurt. Product analytics: PostHog, Frankfurt. Uptime monitoring: Better Stack.

We give the current, named list of every provider above to any customer who asks: security@curviate.com.

Data processing agreement

A data processing agreement under Article 28 of the GDPR is available to business customers on request. Email security@curviate.com and we will send you the current version.

Outside the EU

All customer LinkedIn data, account data, and telemetry are processed and stored exclusively in EU regions of our sub-processors. A few providers we rely on (Stripe and Sentry, for example) are headquartered outside the EU/EEA; where that applies, it's covered by their own GDPR safeguards, typically the EU Standard Contractual Clauses.

How long we keep it

DataRetention
Account and workspace dataWhile your account is active
Closed accountDeleted immediately and irreversibly; see Deleting your account below
LinkedIn credentialsUntil you disconnect that account
LinkedIn contentNot stored; any transient cache clears within 1 hour, never indexed, never used for training
API keysUntil you revoke or rotate them
Connector (OAuth) authorizationsAccess token ~1 hour; refresh token up to ~12 months, or until you revoke it, whichever comes first
Billing recordsAs required by German tax law, currently up to 10 years
LogsA short operational window; metadata only, never message content

The 12-month figure above is a server-side credential for a connected AI agent or app. It is not a cookie and doesn't touch your browser session; see Cookies below for that. You can see and revoke every connector from Authorized applications in your dashboard at any time.

Cookies

We keep cookies to a minimum, and ask before anything beyond the essentials runs.

Strictly necessary, no consent needed:

NamePurposeExpiry
cc_cookieRemembers your cookie choice12 months
curviate-themeRemembers light/dark mode (local storage, not a cookie)Persistent
sb-*-auth-tokenKeeps you signed inWhile active; cleared on sign-out

Analytics, only if you accept:

NamePurposeExpiry
_gaGoogle Analytics: distinguishes visitors2 years
_gidGoogle Analytics: distinguishes visitors24 hours
_ga_<container id>Google Analytics: persists session state2 years

No advertising cookies, ever. Accept and reject are equally easy, and you can change your mind any time via Cookie Preferences in the footer; we won't ask again for 12 months unless something material changes. Our LinkedIn connect flow and OAuth authorization screen never set anything beyond the essentials, so no banner appears there.

Connecting an AI agent or app

Curviate is built for AI agents and automated clients as much as for people. If you connect an app like Claude, or your own code, via an API key or an OAuth connector, it can act on your workspace within the access you gave it. What it does with anything it receives back, including what it sends to its own AI model, is between you and that provider; review its practices before connecting it. Review and revoke any connection any time from your dashboard.

Deleting your account

You can delete your account yourself, from Settings in your dashboard. It takes effect immediately and it cannot be undone. There is no grace period and nothing to restore afterwards, so export anything you want to keep before you start.

Deleting removes your sign-in identity, which frees your email address for reuse straight away, along with your profile, your workspace membership and settings, your API keys, and your seats. For any connected LinkedIn account, we instruct our infrastructure provider to delete it, and your access ends immediately. Records of the connection itself can remain in our systems; email privacy@curviate.com if you need those removed as well. LinkedIn content was never stored in the first place, so there is none of it to delete.

A few things are kept on purpose. We would rather name them than claim a clean sweep:

  • Billing records, for as long as German tax law requires. They hold plan, seat count, amount, and payment references; no name, no email, no LinkedIn data.
  • A record that the deletion happened, so we can show you or a regulator that we did it.
  • A security log of which requests were made, kept for 90 days and then removed automatically. It records that a request happened, never what was in it.
  • A one-way fingerprint, if you used a free trial, that lets us recognise a repeat trial. It holds no readable identifier and cannot be read back into your name, your email, or your LinkedIn profile.

Internal workspace identifiers can also remain in operational records such as queue entries and rate-limit counters. Those carry no name, no email, and no content. If you want to know exactly what is left for your own account, ask us at privacy@curviate.com.

Your rights

You can access, correct, delete, restrict, or object to your data, port it elsewhere, and withdraw consent at any time: email privacy@curviate.com. A copy of your data in a machine-readable format is available on request. We don't make automated decisions about you that have a legal or similarly significant effect. You can also complain to a supervisory authority; ours is the Landesbeauftragte für Datenschutz und Informationsfreiheit Nordrhein-Westfalen (LDI NRW), www.ldi.nrw.de, though you're free to complain to the one in your own country instead.

Keeping it secure

Credentials are encrypted and never logged, returned, or shared. LinkedIn actions run through native, humanized flows; full detail is on our Security & Compliance page. If a breach puts your rights at risk, we'll notify the authorities and you, as GDPR requires. Curviate isn't directed at, or offered to, anyone under 16.

Changes

We'll update this page when our practices change, and reset the cookie prompt if the change is material.

Contact