SDKs
JavaScript SDK
The official TypeScript/JavaScript client — the whole API-key surface behind typed methods, with retries, cursor pagination, typed errors, and tools for AI agents.
sproutsdata wraps the entire API-key surface of /v1 behind one typed client: search and look-alikes, the nine entity routes, the four relationship routes, org charts, agent runs, credits and history, feedback and votes, saved lists and CSV export, members, and the free vocabulary lookups.
- Zero runtime dependencies.
- ESM and CommonJS builds.
- TypeScript types bundled; the record types are generated from the API's OpenAPI document.
Request params and response fields are byte-for-byte the raw HTTP contract — the SDK never renames or reshapes them — so everything in the API reference applies verbatim.
The current release is 0.0.1. Until 1.0, a minor version may include breaking changes; each one is listed in the package's changelog. The source, a guide and runnable examples are in the sproutsdata-js repository.
Use cases#
Instantiate the client once and call typed methods instead of hand-rolling fetch, retries and pagination. You get compile-time checking on every request and response, automatic retries on the retryable statuses, and cursor pagination handled for you.
Use the pagination helper instead of a manual cursor loop. The complete result set (up to the depth limit) comes from a single loop, with the cursor rules — same body between pages, expiry, depth — followed by the SDK rather than remembered by you.
Hand a model search, look-alike search and record lookup as tools, for OpenAI or Anthropic directly, or through the Vercel AI SDK and LangChain adapters. The tools have the same names and results as the MCP server.
Install and set up#
- Install the package
npm install sproutsdataRequires Node.js 18 or newer (the SDK uses the native
fetch). Nothing else is pulled in. - Set your API key
Create a key as described in Authentication and export it as
SPROUTSDATA_API_KEY. Use a test key (sk_dsp_test_…) while developing: test keys are metered but charged zero credits. The key decides live or test only, not which deployment you reach; that comes from the base URL. There is no live/test option on the client, and every response echoes the key's choice back inmeta.environment.The client calls the production API unless
SPROUTSDATA_BASE_URL(or thebaseUrloption) names another deployment. If you are not on production, set it to the base URL for your environment.DSP_API_KEYandDSP_BASE_URLare still read, with a deprecation warning, and stop being read at 1.0. - Create a client
import { SproutsData } from "sproutsdata"; // Reads the key from the SPROUTSDATA_API_KEY environment variable… const client = new SproutsData(); // …or pass it explicitly. const explicit = new SproutsData({ apiKey: "sk_dsp_live_…" });The client sends your key in the
X-API-Keyheader on every request.SproutsDatais also the package's default export.
Search#
/v1/searchclient.search(params) takes the raw request body — same names, same snake_case — and resolves to the standard envelope:
const page = await client.search({
query: "industrial robotics companies in Germany",
mode: "auto",
category: "company",
limit: 10,
});
for (const hit of page.results) {
console.log(hit.id, hit.score, hit["account_name"]);
}
// Read the envelope honestly:
console.log(page.total, page.total_is_estimate); // count, and whether it is exact
console.log(page.suggestion); // null, or "you asked this in the wrong category"mode: "fast"(the default) matches the words without parsing them, so a constraint stated only in words — a country, a headcount — is not applied.mode: "auto"parses the query into filters first and reports what it inferred inpage.resolved_search. Withmode: "auto"and a query only,category: "auto"sends no category, so the server classifies the query itself.categoryotherwise defaults tocompany.mode: "deep"hands the query to the agentic engine. It searchescompanyorperson, returns one page with no cursor, and reads only the query: the SDK refusesfilters,sortand domain lists alongside it with aClientValidationError, because the server would ignore them on a billed call. For exhaustive research, use an agent run.
For company and person, filters is typed against the DiscoverFilters vocabulary the filters guide documents, so a misspelt filter is a compile error:
await client.search({
category: "company",
filters: {
industry: { include: ["Software & Technology"] },
employee_count: { gte: 500, lte: 5000 },
geo: { places: [{ value: "Germany", level: "country" }] },
},
});The server checks the rest. A filter it cannot read is a 422 surfaced as InvalidRequestError, with the filter's path on err.fieldErrors and in the message. The document categories take free-form filters, checked by the retrieval engine, whose 422 carries only its detail.
keyword_query is keyword search over company text, as an AND / OR / NOT tree:
await client.search({
keyword_query: { and: ["payments", { or: ["stripe", "adyen"] }, { not: "crypto" }] },
});It applies to companies only, cannot be combined with query or with mode: "deep", and is billed as a fast search. It is rolling out: a deployment that does not have it yet answers 422 for the field.
A hit's id is not a lookup key. lookupRef(hit) returns how to look the row up — by domain for a company, by LinkedIn URL for a person (entities.persons) — or null when nothing on the hit resolves, as for a document hit:
import { lookupRef } from "sproutsdata";
const ref = page.results[0] ? lookupRef(page.results[0]) : null; // { by: "domain", value: "…" }
if (ref) {
const records = await client.entities.companies({ ids: [ref.value], by: ref.by });
}Sorting
sort takes up to three clauses, strongest key first, replaces relevance ordering, and needs an explicit category. Which fields a category can sort on is the server's to say, and a refusal names the clause:
await client.search({
category: "company",
filters: { industry: { include: ["Software & Technology"] } },
sort: [{ field: "revenue" }, { field: "account_name", direction: "asc" }],
});
// Read the live catalogue rather than hardcoding one:
const catalogue = await client.sorts("person");
for (const s of catalogue.sorts) {
console.log(s.field, s.label, s.applies_to, s.direction_labels);
}DiscoverSortField types the company/person vocabulary; document categories pass their own names through. See Sort fields.
Pagination#
client.searchPages(params, options) is an async generator that follows next_cursor until it runs out, resending the body verbatim — which is exactly what the server's signed cursor requires:
for await (const page of client.searchPages(
{ query: "fintech companies in Germany", limit: 100 },
{ maxResults: 500 },
)) {
for (const hit of page.results) console.log(hit.id);
}The iterator copies params before the first request, so changing your own objects afterwards cannot break the walk. maxResults stops it after that many rows, and its limit option is the one value that may change between pages. Iteration stops at the API's 10,000-row depth limit with one warning, rather than raising after you have paid for every page before it. Watch page.truncated: when it is true, has_more: false does not mean you have everything.
similarPages, relationships.findPages / .reachPages / .askPages, history.listPages and runs.resultsPages work the same way, and lists.iterItems walks a saved list item by item.
Some lists do not paginate at all, and the SDK reflects that rather than pretending:
mode: "deep"yields exactly one page.relationships.institutionsand the org-chart routes are single aggregations with no cursor.feedback.listis the one offset-paginated list on the platform.
Cursor semantics — freezing, expiry, depth — are in Pagination.
Similar#
/v1/similarconst lookalikes = await client.similar({
anchors: [{ value: "stripe.com" }, { value: "adyen.com" }], // by: "domain" is the default
combine: "or",
filters: { employee_count: { gte: 500 } },
});combine: "and" averages the anchors into one composite ideal; "or" returns companies resembling any one of them. With several dissimilar anchors, "and" can describe a company that does not exist, so "or" is usually the better choice. Anchors decide who is similar; filters decides which of those come back.
Entities#
/v1/entities/{category}One method per category: companies, persons, patents, news, trends, marketReports, fundings, academicReports, jobOpenings. Records are fully typed (CompanyRecord, PatentRecord, …) and billed 1 credit per record found; misses are free.
const lookup = await client.entities.companies({
ids: ["stripe.com", "adyen.com"],
by: "domain",
fields: ["account_name", "industry", "employee_count"], // omit for the full record
});
for (const hit of lookup.results) {
if (!hit.found) continue; // misses are returned, never silently dropped
const record = hit.matches[0]!;
console.log(record.account_name, record.employee_count);
}// Look a document up by name — expect several matches per entry:
const rounds = await client.entities.fundings({ ids: ["stripe"], by: "name" });
for (const match of rounds.results[0]!.matches) {
console.log(match.round_type, match.announced_on);
}Without by, ids are record ids. Companies and people accept 1,000 record ids per call, or 50 with by set; the document categories accept 100, or 50 with by: "name". The server enforces these limits and names the limit in its 422. news, marketReports, fundings and jobOpenings take by: "id" | "name"; the SDK sends "id" unless you pass "name", so a batch of record ids is never read as names.
Relationships#
/v1/relationshipsFour routes, one namespace. Pick by the shape of the question, or let ask pick:
// Two named companies — whose people come back is the FIRST one.
const paths = await client.relationships.find({
relation: "between_companies",
companies: [{ value: "stripe.com" }, { value: "adyen.com" }],
});
for (const person of paths.results) {
console.log(person.full_name, "via", person.via.map((v) => v.name));
}
// One named person — who can they reach there?
const reachable = await client.relationships.reach({
person: { li_url: "https://www.linkedin.com/in/ada-lovelace/" },
target_company: { value: "adyen.com", by: "domain" },
});
// The bridges alone — no ranked people, no cursor.
const shared = await client.relationships.institutions({
companies: [{ value: "stripe.com" }, { value: "adyen.com" }],
});Every row carries via: the institutions that connect the two sides. A connection is a shared institution, not an acquaintance — two people who both worked at the same company may never have met.
Ask in words and the server decides which shape answers it.
const answer = await client.relationships.ask({
query: "who at Adyen do we know from Stripe",
});
console.log(answer.route); // which question the sentence was taken to be
console.log(answer.reading.span, answer.reading.warnings); // what was understood, widened or droppedask is billed under its own action, dsp.relationships.ask. The structural rules of the typed routes — for example, between_companies takes exactly two companies — are enforced by the server, whose refusal names the fix. The SDK refuses one combination itself: query alongside relation or companies.
Org charts#
/v1/org-chartOne company's people counted by department and seniority — a matrix, not a reporting tree, because the corpus holds no manager edges. Identify the company by domain or linkedin_url, exactly one:
const grid = await client.orgChart.retrieve({
domain: "stripe.com",
country: ["Netherlands"],
});
console.log(grid.total);
for (const cell of grid.cells) {
console.log(cell.department, cell.seniority, cell.count);
}
const tree = await client.orgChart.departments({ domain: "stripe.com" }); // teams, with sub-departments
const where = await client.orgChart.locations({ domain: "stripe.com" }); // country → state → citygrid.total counts everyone; the cells count only the people the corpus could place in both a department and a level, so the cells do not sum to the total. Report both. Filters narrow the population before counting, so a filtered chart is that population's org chart rather than the company's with cells blanked. UNKNOWN_LOCATION ("__unknown__") names people with no value at a level; it is exported. A deployment without the org-chart upstream answers 501, raised as FeatureNotAvailableError before anything is charged.
Agent runs#
/v1/agents/runsResearch rather than retrieval: candidates verified against criteria you state in words. A run takes minutes, not milliseconds, and is the only asynchronous surface in the API.
const handle = await client.runs.create({
query: "series B fintechs in DACH with an in-house ML team",
effort: "high",
criteria: ["has an in-house ML team"],
});
for await (const event of client.runs.events(handle.run_id)) {
console.log(event.type); // optional progress stream
}
const run = await client.runs.wait(handle.run_id);
console.log(run.status, run.credits_charged, run.coverage?.universe_exhausted);
if (run.status === "completed") {
for await (const page of client.runs.resultsPages(run.run_id)) {
for (const row of page.results) console.log(row["account_name"], row["domain"]);
}
}runs.createsends anIdempotency-Key, which the server requires. The SDK generates one per call and resends it on each of its own retries; pass{ idempotencyKey }as the second argument when a retry may come from outside the SDK. The server returns the first run for a key it has already seen and ignores the new body, so use a new key for a new question.runs.eventsstreams progress and never reconnects on its own. To resume after a drop, call it again with{ lastEventId }.runs.waitreturns afailedorexpiredrun rather than throwing. Past its deadline (15 minutes by default,timeoutMsto change it) it throwsRunWaitTimeoutErrorwith the last state on.run, and the run itself carries on.runs.retrieve,runs.listandruns.cancelread one run, list the workspace's recent runs, and cancel a run.
Credits, usage and history#
/v1/creditsconst balance = await client.credits.retrieve();
console.log(balance.spendable); // the only number that predicts the next charge
const usage = await client.credits.usage({
granularity: "day", // "hour" | "day" | "month" | "total"
from: new Date("2026-07-01"), // Date or ISO string
to: new Date("2026-08-01"),
});
console.log(usage.total_requests, usage.total_credits);
// This credential's own calls, with the request that made each one:
for await (const page of client.history.listPages({ action_type: "dsp.search.fast" })) {
for (const call of page.results) {
console.log(call.occurred_at, call.query, call.status, call.credits_charged);
}
}
const detail = await client.history.retrieve(4821); // detail.request: the exact body, to replay
const daily = await client.history.metrics({ from: "2026-08-01" });Gate on spendable. Do not add the allocated and shared pools together: the shared pool is reported for visibility but is not available to this platform. client.credits is the billing view and client.history the operational one. A call is pending from the moment it starts costing something until its outcome is written, then ok or failed; failureRate(metrics) leaves pending calls out. All of these are free. What each call costs, and how a search is charged across pages, is in Credits & usage.
Feedback#
/v1/feedbackReport a single result or a whole result set against one of your own requests or agent runs. Free:
const page = await client.search({ query: "lenders in Germany", category: "company" });
await client.feedback.create({
subject_ref: { kind: "request", id: page.meta.request_id },
target: { kind: "result_set" },
tags: ["too_few"],
expected: { should_include: ["n26.com"] },
});
const vocabulary = await client.feedback.tags(); // render the form from this, not a constant
const mine = await client.feedback.list({ subject_id: page.meta.request_id });A tag says something was off; expected says what should have come back, and only the second settles anything. Target one row with { kind: "result", entity_id }. The tag vocabulary lives on the server, and a tag used at the wrong level is refused there, by name.
await client.feedback.votes.cast({
request_id: page.meta.request_id,
vote: "dislike",
entity_category: "company",
reason_code: "too_few",
});
await client.feedback.create({
context: "help",
target: { kind: "result_set" },
comment: "The export button does nothing.",
});A vote is a like or dislike on a whole answer, or a flag on one record (pass its entity_id); casting the same vote twice changes nothing, and feedback.votes.list(requestId) reads them back. context: "help" files a problem report about the product, with no subject.
Saved lists and export#
/v1/listsconst saved = await client.lists.create("Fintech targets");
await client.lists.addItems(saved.id, [
{ entity_category: "company", entity_id: "stripe.com" },
{ entity_category: "company", entity_id: "adyen.com" },
]);
for await (const item of client.lists.iterItems(saved.id)) {
console.log(item.entity_id, item.available, item.record);
}
const csv = await client.searchExport({
items: [{ entity_category: "company", entity_id: "stripe.com" }],
}); // csv.content (bytes), csv.text(), csv.filenameLists belong to the workspace; lists.list, rename, delete, removeItems and export complete the set. Name a record by its natural id — a domain, a LinkedIn URL, a patent number, a DOI — never by a search hit's id. Adding is idempotent, Do-Not-Contact records are refused (suppressed), and a free workspace over its saved-items cap gets a ConflictError (lists.item_cap_reached). CSV export answers NotFoundError (export.not_enabled) until it is switched on. All free. See Saved lists.
Members#
/v1/members/resolveconst member = await client.members.resolve("ada@example.com");
if (member.found && member.state === "active") {
// attribute the work to member.user_id
}Resolves one email against the key's own organization, for attributing work to a person before spending credits. found: false is a normal answer meaning "not a member"; an error means the server could not tell, and must not be read as "not a member". Free.
Vocabulary lookups#
All free, all safe to call on every keystroke:
for await (const value of client.iterFilterValues("industry", { search: "soft" })) {
console.log(value);
}
// Two-level filters: parents first, then one branch as the user expands it.
const parents = await client.filterValues("department", { level: "parents" });
const children = await client.filterValues("department", { parent: "Engineering" });
// A document category's own vocabulary — a separate route, because the
// namespaces collide (`employment_type` means two different things).
const statuses = await client.documentFilterValues("patent", "status");
const coverage = await client.coverage({ category: "company" });
console.log(coverage.segments, coverage.not_in_index);geo is not a valid name here — it spans four levels with no single vocabulary — so enumerate country, state, city or region and send each answer back with the matching level. A segment in not_in_index is absent, not empty: searching it is a question the deployment cannot answer, not a quiet zero.
Tools for models#
sproutsdata/tools gives a model three tools — sprouts_search, sprouts_similar and sprouts_entity_lookup — with the same names, input schemas and results as the MCP server. It builds the tool definitions and runs the calls a model makes, for OpenAI or Anthropic, without importing either SDK:
import { SproutsData } from "sproutsdata";
import { Toolset } from "sproutsdata/tools";
const tools = new Toolset(new SproutsData(), { maxResults: 10 });
// Send the definitions with every model request…
const definitions = tools.openai(); // Chat Completions; { api: "responses" } for the Responses API
// …and when a reply asks for tools, run them and append the answers to the conversation.
const toolMessages = await tools.handleOpenAIToolCalls(message);message is the assistant message from Chat Completions, or the whole response from the Responses API. tools.anthropic() and tools.handleAnthropicToolUse(response) do the same for Anthropic. Every call is answered, including one naming an unknown tool, so the conversation can always continue. Errors a model can act on come back as data; a rejected key, a missing scope or exhausted credits is thrown. The full description of each tool is in the tools guide.
Framework adapters#
Two companion packages expose the same three tools in the agent framework you already use. Both are thin layers over sproutsdata/tools, with sproutsdata as a peer dependency, and are versioned and published separately:
| Package | Framework | Node.js |
|---|---|---|
@sproutsdata.ai/ai-sdk | Vercel AI SDK (ai 7) | 22.12 or newer |
@sproutsdata.ai/langchain | LangChain (@langchain/core 1), tools and a retriever | 20 or newer |
npm install @sproutsdata.ai/ai-sdk sproutsdata aiimport { generateText, stepCountIs } from "ai";
import { raiseSproutsDataErrors, sproutsDataTools } from "@sproutsdata.ai/ai-sdk";
const { text } = await generateText({
model: "anthropic/claude-sonnet-4-5",
tools: sproutsDataTools(), // reads SPROUTSDATA_API_KEY
stopWhen: [stepCountIs(5), raiseSproutsDataErrors],
prompt: "Find VPs of Marketing at fintech companies in Germany.",
});raiseSproutsDataErrors makes a rejected key, a missing scope or an empty credit balance reject the call instead of reaching the model as a tool error. In both packages, sproutsDataTools() takes the settings the model cannot change:
| Option | Effect |
|---|---|
include | The tools to offer: full names, or "search", "similar", "lookup". Default: all three. |
fixed | Per tool, arguments you set. They are removed from the schema the model sees. |
defaults | Per tool, arguments used when the model leaves them out. |
maxResults | The most rows one call returns, from 1 to 25 (the default). A model asking for more gets this many, and a caveat says so. |
baselineFilters | Filters merged into every search and look-alike call. Where the model set the same key, yours wins, and a caveat tells the model. |
They also take the connection settings (apiKey, baseUrl, timeoutMs, maxRetries) or your own client. A missing key or an invalid option throws ClientValidationError when the tools are built, not in the middle of an agent run. The OpenAI Agents SDK needs no package: a short recipe wires it to sproutsdata/tools. If your agent speaks MCP instead, the hosted MCP server needs no package at all.
Configuration#
Everything on SproutsDataOptions is optional:
| Option | Default | Notes |
|---|---|---|
apiKey | SPROUTSDATA_API_KEY env var | Required from one or the other. sk_dsp_live_… or sk_dsp_test_…. |
baseUrl | SPROUTSDATA_BASE_URL env var, then the production API | Must be https://; plain http:// is accepted for localhost and 127.0.0.1 only. See Base URL. |
timeoutMs | 60000 | Per attempt, to the last byte of the body, not per call — see Pitfalls. |
maxRetries | 2 | 0 disables retries. |
fetch | globalThis.fetch | Inject your own for proxies or tests. |
defaultHeaders | {} | Merged into every request; cannot override X-API-Key or User-Agent. Origin, Referer and X-SproutsData-Client are refused. |
integration | — | For a framework built on this client: its <name>/<version>, sent as X-SproutsData-Client. |
dangerouslyAllowBrowser | false | Leave it off — keys are server-side only. |
Every method also takes per-request options as its last argument, overriding the client defaults for that call. client.withOptions() returns a copy of the client with different defaults:
const controller = new AbortController();
await client.search(
{ query: "fintech" },
{ timeoutMs: 15_000, maxRetries: 0, signal: controller.signal },
);
await client.search({ query: "fintech" }, { requestId: "job-42" }); // sent as X-Request-Id
await client.withOptions({ maxRetries: 0 }).search({ query: "fintech" });requestId must be 1–128 characters of letters, digits, _, ., : and -; a page iterator sends it with the first page only. headers adds headers to one call, under the same rules as defaultHeaders.
Error handling#
Errors are the same RFC 9457 problems the raw API returns, surfaced as typed classes. Catch by instanceof:
import {
APIError,
FeatureNotAvailableError,
InsufficientCreditsError,
RateLimitError,
} from "sproutsdata";
try {
await client.search({ query: "fintech" });
} catch (err) {
if (err instanceof InsufficientCreditsError) {
// Never retried: topping up credits is the only fix.
console.error(`need ${err.required}, have ${err.available}`);
} else if (err instanceof RateLimitError) {
// Built-in retries are already exhausted by the time you see this.
console.warn(`Rate limited — retry after ${err.retryAfter ?? "?"}s`);
} else if (err instanceof FeatureNotAvailableError) {
// Not available for this request, such as an org chart on a deployment without one.
} else if (err instanceof APIError) {
console.error(err.status, err.code, err.requestId);
} else {
throw err; // ClientValidationError, connection/timeout/abort errors
}
}Everything the SDK throws extends SproutsDataError:
| Class | Raised for |
|---|---|
ClientValidationError | A request refused before sending; nothing was sent or billed |
InvalidRequestError | 400, 415, 422 — with CursorInvalidError, CursorExpiredError and PaginationDepthExceededError as subclasses |
AuthenticationError | 401 |
InsufficientCreditsError | 402 — carries required and available |
PermissionDeniedError | 403 |
NotFoundError | 404 |
ConflictError | 409, such as lists.item_cap_reached — IdempotencyConflictError when a request with the same Idempotency-Key is still running |
RequestTooLargeError | 413 |
RateLimitError | 429 — carries retryAfter (seconds) |
InternalServerError | 500 |
FeatureNotAvailableError | 501 |
ServiceUnavailableError | 503 |
UpstreamTimeoutError | 504 |
APIConnectionError | A network failure; APITimeoutError (an attempt exceeded timeoutMs) extends it |
APIUserAbortError | Your signal fired |
APIResponseValidationError | A success response the SDK cannot read |
RunWaitTimeoutError | runs.wait passed its deadline — carries the last state on .run |
Every APIError carries status, code, title, detail, requestId, headers and the raw problem document. A 422 lists the offending field paths on err.fieldErrors. retryAfter is set when the server sent Retry-After, and reachedServer says whether a failed request may have reached the server. ErrorCode is exported as the closed set of machine-readable codes — switch on err.code, never on the prose. For an authentication failure, requestId is the only thing that tells one apart from another, so log it.
Retries#
Retries are automatic, bounded by maxRetries, and depend on what repeating the call could cost. The server charges search, look-alikes, lookups, relationships and org charts once it has done the work, so a billed request that may have reached the server is raised rather than repeated:
| Outcome | Free routes (GETs, run cancel, members.resolve, votes, list edits) | Billed routes and exports | runs.create | feedback.create, lists.create, lists.delete |
|---|---|---|---|---|
| Never arrived (connection refused, DNS failure, connect timeout) | Retried | Retried | Retried | Retried |
Sent, no answer (timeout, reset after sending, 502, 408) | Retried | Raised | Retried | Raised |
429 (Retry-After up to 60 s), 503, 504 | Retried | Retried | Retried | Retried |
500 | Retried | Retried | Retried | Not retried |
When a billed, feedback or list create/delete request is raised from the "sent, no answer" row, the error has reachedServer: true and its message says what that means: a charge may have happened, or a duplicate report or list may exist. Check client.history.list(), client.feedback.list() or client.lists.list() before trying again. Never retried: 400, 401, 402 (buy credits, don't wait), 403, 404, 409, 413, 415, 422, 501, and 500 credits.unknown_action.
Every billed call sends an Idempotency-Key, one per call and resent on its retries. A retry with that key on the same UTC day that returns the same rows is not charged again; one that returns different rows is.
The wait before each retry is full-jitter exponential backoff: a random delay between 0 and a ceiling that starts at 500 ms and doubles per attempt, up to 8 s. When the server sends Retry-After, the SDK waits exactly that long, up to 60 s; a longer Retry-After is raised at once, with retryAfter attached, rather than slept through. Rate limits are per workspace and per operation; see Rate limits.
SDK vs raw HTTP#
The naming rules, in full:
- Request params and response fields are snake_case, exactly as on the wire —
include_domains,next_cursor,total_is_estimate. The SDK never camelizes data. - Only method names and SDK options are camelCase —
searchPages,entities.marketReports,orgChart.departments,timeoutMs. - Envelopes are returned verbatim — what
fetchwould have parsed is what you get, typed. See the response envelope.
What the SDK adds over hand-rolled fetch: the auth header, retries with backoff that never repeat a charge, per-attempt timeouts, typed errors, pagination generators, typed filters, and tools for models. Limits and vocabulary rules are left to the server, which names the field in its refusal.
Pitfalls#
timeoutMsis per attempt, not per call. With the defaults (maxRetries: 2), worst-case wall time is roughly three timeouts plus backoff on calls that are not billed; a billed call stops after its first timeout, because it is never repeated once it may have reached the server. For a hard wall-clock ceiling, pass your ownAbortSignal.deepsearches take time by design. Don't pairmode: "deep"with an aggressivetimeoutMs— you will pay for work you then abandon.byon entities drops the id cap to 50. Resolve raw identifiers (domains, names, LinkedIn URLs) in batches of 50, or pass record ids for the full batch.matches[0]is a choice, not a shortcut. It is correct for an id lookup and silently lossy for aby: "name"one.- Search needs
query,filtersorkeyword_query.search({})throwsClientValidationErrorbefore anything is sent. sortis part of what the cursor is signed against. Changing it mid-pagination invalidates the cursor;searchPagesresends the body verbatim for exactly this reason.prior_companyis retired. It stays declared so old code compiles, and is refused locally; useemployment_historywith one group.- Live vs test is the key's decision, not the client's. The deployment comes from the base URL. Swap the key, not a config flag, and verify with
meta.environmenton any response. - Keep
meta.request_id. Log it on every failure — it is the fastest way for support to find your request.
