Developer toolsDocs

TypeScript SDK

Call Drex's decision model and run Perception's document tools from Node with nace-sdk — client options, calibrated questions, typed answers, parse, split, classify, extract, ground, uploads, jobs, saved schemas, errors, retries and logging.

The nace-sdk npm package is the official TypeScript client for https://console.nace.ai. One client covers Drex's decision model, systemOne and models.list, and Perception's document tools: the five document jobs, uploads, jobs, job files and saved extraction schemas. It needs Node 20 or later and ships as both ESM and CommonJS.

The client is built on the TypeSafe SDK, so systemOne, noul, choice, score, the inferred answer types, RetryPolicy and the error classes keep the names they have in @typesafe-ai/sdk. Drex takes instructions and criteria as text only, and it has its own classes for 402 and 529; Migrate from TypeSafe lists every difference.

npm install nace-sdk
export NACE_API_KEY="nace_sk_..."

Create a key as shown in Authentication, and keep it on a server: it spends your account's credit, and a browser can't call the /v1 routes from another origin (see Browsers). The samples on this page use a client made with new NaceClient(), which reads NACE_API_KEY, NACE_BASE_URL, NACE_DEFAULT_DECISION_MODEL and NACE_LOG_LEVEL.

Configure the client

new NaceClient(config?)

ParameterTypeRequiredDescription
apiKeystringYes, or NACE_API_KEYYour nace_sk_ key.
baseURLstringNoAPI root, with trailing slashes removed. Default: NACE_BASE_URL, then https://console.nace.ai.
defaultModelstringNoThe model for calls that don't name one. Default: NACE_DEFAULT_DECISION_MODEL, then drex-latest.
timeoutnumberNoMilliseconds per attempt, more than 0. Default: 60000, because Drex waits up to 55 s for the model before it answers 529. A document create adds its wait_seconds to it.
retryPartial<RetryPolicy>NoRetry overrides. Fields you leave out keep the defaults in Retries.
logLevelLogLevelNo"debug", "info", "warn", "error" or "off". Default: NACE_LOG_LEVEL, then "warn". See Logging.
loggerLoggerNoWhere log lines go. Default: console, with a [nace-sdk] prefix.
defaultHeadersRecord<string, string>NoHeaders sent with every request to Drex. They aren't sent to the document service's upload URLs or to signed links.
fetchFetchNoThe fetch to send requests with, for a proxy agent or a test double. Default: the global fetch.
dangerouslyAllowBrowserbooleanNoLet the constructor run where browser globals exist. Default: false. See Browsers.

config is a NaceClientConfig. Fetch is (input: string, init?: RequestInit) => Promise<Response>, the shape of the global fetch.

An option you pass wins over its environment variable, and the variable wins over the default. Blank or whitespace-only environment values are ignored; an option you pass is used as it is, even when empty. The variables are read from process.env, so in a runtime without it, pass the options. The client doesn't read the CLI's ~/.nace/config.toml: pass apiKey, or set NACE_API_KEY.

VariableOptionENV key
NACE_API_KEYapiKeyENV.apiKey
NACE_BASE_URLbaseURLENV.baseURL
NACE_DEFAULT_DECISION_MODELdefaultModelENV.defaultModel
NACE_LOG_LEVELlogLevelENV.logLevel

Each variable's name from before the package was renamed from drex-sdk still works when the new one is unset or blank: DREX_API_KEY, DREX_BASE_URL, DREX_DEFAULT_MODEL and DREX_LOG_LEVEL (LEGACY_ENV). The new name wins when both are set.

The constructor throws a NaceError when neither apiKey nor NACE_API_KEY is set, when timeout or a retry field is out of range, when the log level is unknown (from the option or from NACE_LOG_LEVEL), when the runtime has no global fetch and you passed none, and in a browser without dangerouslyAllowBrowser.

client.ts
import { NaceClient } from "nace-sdk";

const client = new NaceClient({
  timeout: 90_000,
  retry: { maxRetries: 4, timeoutMs: 500_000 }, // room for 5 attempts of 90 s and the waits between them
  defaultHeaders: { "X-Team": "support" },
  logLevel: "info",
});

Client properties

Every option resolves to a read-only property. The API key is never exposed.

PropertyTypeDescription
baseURLstringThe API root in use.
defaultModelstringThe model for calls that don't name one.
timeoutnumberMilliseconds per attempt.
retryRetryPolicyThe full retry policy, with your overrides applied.
logLevelLogLevelThe resolved log level.
loggerLoggerYour logger, or the console one, filtered to logLevel.
defaultHeadersReadonly<Record<string, string>>A copy of defaultHeaders.
fetchFetchThe fetch in use.
modelsModelsList models.
documentsDocumentsDocument jobs and uploads. See Create a job and Uploads.
jobsJobsReading, waiting for and deleting document jobs, and their files. See Jobs and Job files.
extractionSchemasExtractionSchemasSaved extraction schemas. See Saved schemas.

Browsers

new NaceClient() throws in a browser, because the page would hand your API key to anyone who opens it. dangerouslyAllowBrowser: true only turns that check off. It doesn't make Drex reachable: the /v1 routes send no Access-Control-Allow-Origin header, so the browser blocks a page on another origin from reading their answers, and every call fails with APIConnectionError. The option is for code that has browser globals but no cross-origin checks, such as tests under jsdom.

Call Drex from your server. To move files in a browser, mint an upload grant on the server and let the browser send the file to the document service, and hand the browser signed links from jobs.fileLink; see Upload grants.

Per-call options

Every method takes a RequestOptions as its last argument. A few document methods take more, or less; their sections say so.

ParameterTypeRequiredDescription
signalAbortSignalNoCancels the request and any wait before a retry. Throws APIUserAbortError.
timeoutnumberNoMilliseconds per attempt, in place of the client's timeout. The retry budget decides whether another attempt starts; it doesn't shorten an attempt.
retryPartial<RetryPolicy>NoRetry overrides for this call. Fields you leave out keep the client's policy. { maxRetries: 0 } turns retries off.
headersRecord<string, string>NoExtra headers, merged over defaultHeaders. Names compare case-insensitively.

The SDK sets Authorization, Accept, User-Agent, X-Nace-SDK, X-Nace-Runtime, Content-Type, Idempotency-Key and X-Nace-Retry-Count itself, so a value you pass for one of them is replaced or dropped.

const controller = new AbortController();
setTimeout(() => controller.abort(), 10_000);

await client.systemOne(
  { state: "Where is my parcel?", questions: { shipping: noul("Is it about shipping?") } },
  { signal: controller.signal, retry: { maxRetries: 0 }, headers: { "X-Trace-Id": "t-123" } },
);

Ask questions

client.systemOne(request, options?) returns APIPromise<SystemOneResult<Q>>, where Q is the type of your questions.

ParameterTypeRequiredDescription
request.stateEntryTypeYesAny JSON value to evaluate: text, a number, a boolean, an object, an array or null. See State.
request.questionsQuestionsYes1 to 512 questions, each keyed by the name its answer comes back under. Build them with noul(), choice() and score(), or write plain objects. See Questions.
request.modelstringNoThe model for this call. Default: the client's defaultModel.
optionsRequestOptionsNoThe second argument, not a field of request: signal, timeout, retry and headers for this call. See Per-call options.

request is a SystemOneRequest<Q>. Any other field on it is sent in the body as it is.

decide.ts
import { choice, NaceClient, noul, score } from "nace-sdk";

const client = new NaceClient();

const { answers, usage, request_id } = await client.systemOne({
  state: "I was charged twice for my March invoice.",
  questions: {
    wants_refund: noul("Is the customer asking for a refund?"),
    topic: choice("Which topic is it?", { billing: null, shipping: null, other: null }),
    urgency: score("How urgent is it?", ["low", "medium", "high"]),
  },
});

answers.wants_refund.noul; // number
answers.topic.choice; // "billing" | "shipping" | "other"
answers.urgency.probabilities["2"]; // probability of "high"
console.log(usage.input_tokens, request_id);

The answer types come from your questions, so a misspelled question name or label is a compile error. A decision is billed per input token, and only when it returns 200, so the client's retries never bill twice.

systemOne checks the questions before it sends anything, and throws a NaceError for:

  • no questions, or more than 512;
  • a noul with neither instructions nor a non-empty true or false criterion;
  • a choice whose criteria aren't a non-empty map of labels;
  • a score whose criteria aren't a non-empty list.

An instructions or criteria left null is dropped from the body, since Drex refuses null there.

Question helpers

HelperReturnsDescription
noul(instructions?, criteria?)NoulQuestionA yes/no question. instructions defaults to null. criteria is { true?, false? }, a description of each outcome.
choice(instructions, criteria)ChoiceQuestion<T>Picks one label. criteria maps each label to a description, or to null. Throws a NaceError when criteria is a list.
score(instructions, criteria)ScoreQuestion<T>Rates on an ordered rubric: criteria is a list of descriptions, one per score from zero. Throws a NaceError when criteria is a map.

instructions is a string or null. Plain objects work too, such as { type: "noul", instructions: "Is it urgent?" }.

Answer types

SystemOneResult<Q> is what systemOne resolves to:

FieldTypeDescription
modelstringThe model version that answered.
answers{ [K in keyof Q]: ResultFor<Q[K]> }One answer per question, under the question's name.
usageUsageinput_tokens, which Drex bills, and output_tokens.
evaluation_time_msnumberHow long the model took.
request_idstringDrex's request id. Quote it in support requests.
TypeWhat it holds
NoulResponsetype: "noul" and noul, the probability of yes, from 0 to 1.
ChoiceResponse<T>type: "choice", choice (one of your labels), confidence and probabilities, keyed by label.
ScoreResponse<T>type: "score", score (the expected score, which can fall between levels), confidence, legend (your rubric, as a list) and probabilities, keyed by score.
ResultFor<T>The answer type for question type T: NoulResponse, ChoiceResponse or ScoreResponse, keeping your labels.
ScoreOf<T>The score keys of rubric T: "0" | "1" | "2" for a three-entry rubric written in the call, number for one whose length isn't known.
Usageinput_tokens and output_tokens.

See Reading answers for what each number means.

Request types

TypeWhat it is
JsonValueAny JSON value.
EntryTypeThe type of state: a JsonValue.
DescriptionA criterion's description: string | null.
NoulQuestion{ type: "noul", instructions?, criteria?: { true?, false? } | null }.
ChoiceCriteriaLabels mapped to a Description.
ChoiceQuestion<T>{ type: "choice", instructions?, criteria: T }.
ScoreCriteriareadonly [string, ...string[]]: at least one description.
ScoreQuestion<T>{ type: "score", instructions?, criteria: T }.
QuestionNoulQuestion | ChoiceQuestion | ScoreQuestion.
QuestionsQuestions keyed by name.
SystemOneRequest<Q>systemOne's first argument: state, questions and an optional model.
SystemOneRequestPayloadThe body sent to POST /v1/systemone: a SystemOneRequest with model set.

List models

client.models.list(options?) returns APIPromise<ModelCard[]>, the models your key can use. It's free and works at any balance, so it doubles as a key check.

FieldTypeDescription
namestringThe value to pass as model.
descriptionstringWhat the model is.
release_datestringWhen the version was released.
alias_forstring | nullThe version an alias such as drex-latest points to, or the version that now serves a retired one. null for a pinned version.
for (const model of await client.models.list()) {
  console.log(model.name, model.alias_for);
}

See Models.

Sources

Every tool takes a source, typed SourceInput: a Source object, or a plain string.

sourceTypeWhat it is
"https://..."stringA public HTTPS link, sent as a url source with a file_name taken from the URL.
{ type: "url", url, file_name? }UrlSourceA public HTTPS link the document service downloads. file_name is taken from the URL when you leave it out.
{ type: "workspace_file", workspace_id, file_id }WorkspaceFileSourceA file you uploaded. workspaceFile(file) builds it from what documents.upload returns.
{ type: "parse_result", job_id }ParseResultSourceOne of your finished parse jobs, so Parse, Split, Extract and Ground reuse it instead of parsing the document again. Classify doesn't take it.

Source is the union of the three objects. workspaceFile(file) returns the WorkspaceFileSource for an UploadedFile, which documents.upload and completeUploadSession return.

A URL source needs a file_name: the document service picks the parser from its extension, and refuses a type the tool can't read (see Format support). When you leave it out, the SDK takes the URL's last path segment, without its query or fragment and percent-decoded, as long as that segment has an extension: https://example.com/files/Q3%20report.pdf?dl=1 sends Q3 report.pdf. Otherwise it throws a NaceError before sending, and you pass the name yourself. A file_name you pass is sent as it is.

A string that isn't an https:// URL throws a NaceError; http:// links aren't accepted.

// The last segment here isn't a file name: the SDK would send "1706.03762", which the service refuses.
await client.documents.parse({
  source: { type: "url", url: "https://arxiv.org/pdf/1706.03762", file_name: "attention.pdf" },
});

Create a job

The five tools each take one params object and return APIPromise<DocumentJob>. These parameters are shared:

ParameterTypeRequiredDescription
sourceSourceInputYesThe document. See Sources.
wait_secondsnumberNoHold the request open until the job finishes, 0 to 60 (MAX_WAIT_SECONDS). The attempt's timeout grows by as much. Out of range throws a NaceError.
idempotency_keystringNoUp to 200 characters. The same key and body return the same job, billed once. Minted per call when omitted.
namestringNoA display name for the job.
project_idstringNoA UUID you mint to group jobs you submit together.

Every other field goes to the document service as it is, and a field it doesn't know, a misspelled one included, is refused with 422. CreateParams is the shared type; ClassesParams, ExtractParams and GroundParams add each tool's own fields.

The job comes back queued or running, or finished when wait_seconds was long enough. Wait for the rest with jobs.wait:

create.ts
const created = await client.documents.parse({
  source: "https://example.com/report.pdf",
  wait_seconds: 60,
});
const job = created.status === "succeeded" ? created : await client.jobs.wait(created.job_id);
console.log(job.result);

Retries. The minted key stays the same across the SDK's own retries, so they never start or bill a second job. To retry a create yourself, after the SDK's retries ran out, after a 503 job_pending or after a restart, pass your own idempotency_key and send it again. See Retry safely.

A create that holds the request open is retried like any other call, but the default retry budget is 30 seconds in all, so an attempt that fails after waiting longer than that isn't retried. Raise retry.timeoutMs to retry those; see Retries.

The charge's id. A create's x-drex-document-job header carries Drex's own id for the charge. Quote it with the request id when you contact support:

const { data: job, response, requestId } = await client.documents
  .parse({ source: "https://example.com/report.pdf" })
  .withResponse();

console.log(job.job_id, response.headers.get("x-drex-document-job"), requestId);

Parse, Split, Classify and Extract take page_ranges as Array<{ start: number; end: number }>: one-based and inclusive, at most 200 ranges and 500 pages in all.

Parse

client.documents.parse(params, options?) converts a document to Markdown, text, layout blocks or chunks. It takes the shared parameters and:

ParameterTypeRequiredDescription
page_rangesArray<{ start, end }>NoPages to parse. Not with a parse_result source. Default: all pages.
parse_mode"low" | "medium" | "high"NoEffort. low uses native OCR, medium routes to the best parser, high adds page verification and correction. Only low with a parse_result source. Default: "low".
output{ formats?, table_format?, include_images?, include_page_markers?, return_ocr_data? }Noformats (default ["markdown", "blocks"]; also "text"), table_format ("html" or "markdown", default "html"), include_images (default false; not with a parse_result source), include_page_markers (default true), return_ocr_data (word and line OCR geometry, default false; PDF sources only).
figures{ mode?: "include" | "omit" | "describe" }NoDefault "include". "describe" captions each figure with one vision call.
diagrams{ mode?: "omit" | "mermaid" }NoDefault "omit". "mermaid" rewrites flowchart-like figures.
chunking{ strategy?: "none" | "page" | "section" }NoDefault "none". "page" and "section" produce pieces sized for retrieval.
spreadsheet{ sheets?, include_hidden_sheets?, include_hidden_rows?, include_hidden_columns?, include_formulas? }NoWorkbook filters. include_formulas needs output.table_format: "html" and markdown or blocks output. Not with a parse_result source. Default: every sheet, row and column, without formula source text.
passwordstringNoPassword for an encrypted PDF. Never stored or returned. Not with a parse_result source.

A parse_result source with an option marked "not with a parse_result source" set to something other than its default (for spreadsheet: sheets, an include_hidden_* of false, or include_formulas: true), or with a parse_mode other than low, is refused with 422 before a job starts. OCR runs when the file needs it: the document service refuses any ocr setting other than the default with 422 invalid_request.

parse.ts
const job = await client.documents.parse({
  source: { type: "url", url: "https://example.com/report.pdf", file_name: "report.pdf" },
  page_ranges: [{ start: 1, end: 3 }],
  output: { formats: ["markdown", "blocks"] },
  chunking: { strategy: "page" },
});
const done = await client.jobs.wait(job.job_id);

See Parse for the result shape, and Job files for full content too large to inline.

Split

client.documents.split(params, options?) cuts a packet into documents, each with a class. It takes the shared parameters and:

ParameterTypeRequiredDescription
classesArray<{ id, label, description?, subclasses?, instance_key? }>YesThe document classes to cut on. subclasses are { id, label, description? }. instance_key is { name, description, required? }: a value that tells one document of the class from the next, such as an invoice number. See Split's options.
unknown_policy"include" | "force" | "error"No"include" (default) keeps unmatched pages as unclassified, "force" gives them the best class, "error" fails the job.
overlap_policy"exclusive" | "shared_boundary_page"No"exclusive" (default) keeps segments apart; "shared_boundary_page" puts a boundary page in both neighbors.
split_rulesstringNoFree-text guidance on how to cut the packet.
output{ include_content?, materialize_files? }Noinclude_content (default false) puts each segment's Markdown in content; materialize_files (default false) gives each segment a downloadable file.
page_rangesArray<{ start, end }>NoPages to consider. Not for workbooks. Default: all pages.
parse_mode"low" | "medium" | "high"NoHow hard the parse behind the split works. Only low with a parse_result source. Default: "low".
split.ts
const job = await client.documents.split({
  source: { type: "url", url: "https://example.com/packet.pdf", file_name: "packet.pdf" },
  classes: [
    { id: "invoice", label: "Invoice", description: "A supplier invoice" },
    { id: "receipt", label: "Receipt", description: "A payment receipt" },
  ],
});
const done = await client.jobs.wait(job.job_id);

Class ids must be unique and can't contain /, and blank_page, unreadable_content and __ndi_unclassified are reserved. See Split.

Classify

client.documents.classify(params, options?) labels a document, or each page, with your classes. It takes the shared parameters, except that source can't be a parse_result, and:

ParameterTypeRequiredDescription
classesArray<{ id, label, description?, criteria?, subclasses? }>YesYour labels. criteria lists extra rules for the class; subclasses are { id, label, description?, criteria? }.
granularity"document" | "page"NoScore the whole file once, or once per page. Default: "document".
page_rangesArray<{ start, end }>NoPages to read and classify. Not for workbooks. Default: all pages.
unknown_policy"allow" | "force_best"No"allow" (default) may mark a unit unknown; "force_best" commits an ambiguous unit to one of your classes.
output{ max_alternatives?, include_reason? }Nomax_alternatives (1 to 10, default 3) ranked labels per unit; include_reason (default true) adds a short reason per label.
parse_mode"low" | "medium" | "high"NoHow hard the parse behind the classification works. Default: "low".
classify.ts
const job = await client.documents.classify({
  source: { type: "url", url: "https://example.com/invoice.pdf", file_name: "invoice.pdf" },
  classes: [
    { id: "invoice", label: "Invoice", description: "A supplier invoice" },
    { id: "contract", label: "Contract", description: "A signed agreement between two parties" },
  ],
  granularity: "page",
});
const done = await client.jobs.wait(job.job_id);

Class ids must be unique and can't contain /, and other, blank_page, unreadable_content and __ndi_unclassified are reserved. See Classify.

Extract

client.documents.extract(params, options?) fills a JSON Schema from a document. params is an ExtractParams: the shared parameters, and either an inline schema or a saved schema_id, never both.

ParameterTypeRequiredDescription
schemaRecord<string, unknown>Yes, or schema_idThe fields to extract, as a JSON Schema.
schema_idstringYes, or schemaA saved schema's id (sch_...).
schema_versionnumberNoThe saved version to use, with schema_id only. Default: the latest.
instructionsstringNoExtra guidance for filling the declared fields. Can't add fields the schema doesn't list.
page_rangesArray<{ start, end }>NoPages to read. Not with a parse_result source. Default: all pages.
citations{ enabled?, include_source_text? }Noenabled (default true) says where each field was read; include_source_text (default true) adds a short quote.
parse_mode"low" | "medium" | "high"NoHow hard the parse behind the extraction works. Only low with a parse_result source. Default: "low".

Both schema and schema_id, neither of them, or a schema_version without schema_id fail to compile, and throw a NaceError before sending if the types are bypassed.

extract.ts
const parsed = await client.jobs.wait(
  (await client.documents.parse({ source: "https://example.com/invoice.pdf" })).job_id,
);

const job = await client.documents.extract({
  source: { type: "parse_result", job_id: parsed.job_id },
  schema: {
    type: "object",
    properties: { invoice_number: { type: "string" }, total: { type: "number" } },
    required: ["invoice_number", "total"],
  },
});
const done = await client.jobs.wait(job.job_id);
console.log((done.result as { data: unknown }).data); // { invoice_number: 'INV-1042', total: 1200.5 }

See Extract for the result: data, shaped by your schema, and fields, one entry per schema field with its path, value, status (found, not_found or ambiguous), confidence and citations.

Ground

client.documents.ground(params, options?) finds where each quote appears. It takes the shared parameters and:

ParameterTypeRequiredDescription
targetsArray<{ id, text, hint?, sheet?, semantic?, page_hints? }>Yes1 to 30 quotes, each with your own id. Per target: hint is nearby text for a repeated quote, sheet a workbook tab, semantic (default false; deprecated: true fails that target with semantic_mode_deprecated on PDF, Word and PowerPoint sources), and page_hints are one-based pages to search first.
options{ max_matches?, include_previews?, minimum_semantic_score? }Nomax_matches (1 to 50, default 10) locations per target; include_previews (default false) links a cropped image per visual hit; minimum_semantic_score (0 to 1) drops weaker semantic matches, and does nothing on PDF, Word and PowerPoint.

Ground takes no page_ranges and no parse_mode, and semantic belongs on each target: a top-level semantic is refused with 422.

ground.ts
const job = await client.documents.ground({
  source: { type: "url", url: "https://example.com/report.pdf", file_name: "report.pdf" },
  targets: [
    { id: "total", text: "1,200.50" },
    { id: "signer", text: "Jane Smith", hint: "Signed on behalf of the supplier" },
    { id: "late_fee", text: "Late payment fee" },
  ],
});
const done = await client.jobs.wait(job.job_id);

job.result is a plain object (unknown in the types). Each target has id, status and matches; each match has rank, matched_text, match_method, confidence, cropped_image_url and location, whose kind decides its other keys: page_region (page, polygons), text_span (page, char_start, char_end), sheet_range (sheet, a1_range), row_range (start_row, end_row, column), json_pointer (pointer), jsonl_record (row_offset, column) or audio_range (start_ms, end_ms). See Matches.

Uploads

Uploads put a file in your account's workspace, to use as a workspace_file source. They're free and work at any balance. The bytes go straight to the document service with a one-use token, never with your API key, your defaultHeaders or the SDK's X-Drex-* headers: they carry only X-Upload-Token, plus an Idempotency-Key on a one-request upload and on completing a session.

Upload a file

client.documents.upload(content, params?, options?) returns Promise<UploadedFile>.

ParameterTypeRequiredDescription
contentFileContentYesThe bytes: a Blob (a File included), a Uint8Array or an ArrayBuffer.
params.file_namestringYes, unless content is a FileThe file's name, and the default path. Default: the File's own name.
params.pathstringNoWhere the file goes in the workspace, relative. Keep the file's extension: the document service types an upload from its first bytes, and from this extension when those don't settle it. Default: file_name.
params.on_conflictOnConflictNo"reject" (default) refuses a different file at path; "new_version" replaces it with a new version.

params is an UploadParams. A file under 32 MiB goes up in one request through an upload grant. From 32 MiB (UPLOAD_SESSION_THRESHOLD_BYTES) it goes up in parts through an upload session, each part read only as it is sent. options apply in full to the call to Drex; the calls to the document service take its signal, timeout and retry, not headers.

upload.ts
import { openAsBlob } from "node:fs";
import { workspaceFile } from "nace-sdk";

// A Blob backed by the file on disk: the SDK reads it part by part, not all at once.
const blob = await openAsBlob("invoices/invoice.pdf");
const file = await client.documents.upload(blob, {
  file_name: "invoice.pdf",
  path: "invoices/invoice.pdf",
  on_conflict: "new_version",
});

const job = await client.documents.extract({
  source: workspaceFile(file),
  schema: { type: "object", properties: { total: { type: "number" } } },
});

Uploading the same bytes to the same path again returns the file that is there. A different file at a taken path, with on_conflict: "reject", fails with code path_conflict: a ConflictError (409) for a file under 32 MiB, and a NaceError naming the failed upload job and path_conflict for a larger one. upload without a file_name for a non-File throws a NaceError. A file sent in parts that is still assembling after about ten minutes throws a NaceError; the document service keeps assembling it, so upload the same file to the same path again later to get it.

Upload grants

client.documents.createUploadGrant(params?, options?) mints a one-use token for one upload into your workspace, so another client, such as a browser, can upload without your API key. It returns APIPromise<UploadGrant>. params is an UploadGrantParams:

ParameterTypeRequiredDescription
pathstringNoPin the upload to this workspace path.
max_bytesnumberNoRefuse a larger file. Default: the document service's limit for the file type.
total_size_bytesnumberNoThe exact size the file must have.
ttl_secondsnumberNoHow long the token works, 1 to 3600. Default: 3600.
UploadGrant fieldTypeDescription
workspace_idstringYour workspace.
upload_urlstringThe document service's upload URL, not Drex's.
tokenstringSent as X-Upload-Token. Good for one upload.
expires_atstringWhen the token stops working.
max_bytesnumber | nullThe cap you set, or null.

Mint the grant on your server, then let the browser send the file to the grant's upload_url itself:

server.ts
const grant = await client.documents.createUploadGrant({
  path: "invoices/invoice.pdf",
  total_size_bytes: 48_213,
  ttl_seconds: 600,
});
// Hand grant.upload_url and grant.token to the browser. The token works for one upload.
browser.ts
declare const grant: { upload_url: string; token: string };
declare const file: File; // from an <input type="file">

const form = new FormData();
form.append("file", file);
form.append("metadata", JSON.stringify({ path: "invoices/invoice.pdf", total_size_bytes: file.size }));
const response = await fetch(grant.upload_url, {
  method: "POST",
  body: form,
  headers: { "X-Upload-Token": grant.token },
  credentials: "omit",
});
const upload = await response.json(); // upload.result.file has workspace_id and file_id

Send only the X-Upload-Token header, with credentials: "omit", so the request works from any origin. The metadata part also takes on_conflict. Send the file's workspace_id and file_id back to your server and use them as a workspace_file source: the /v1 routes send no Access-Control-Allow-Origin header, so keep every Drex call on your server. See Uploads.

Upload sessions

An upload session takes a large file in parts and can resume after a drop. upload runs one for you from 32 MiB; run one yourself to resume, or to send parts from somewhere else.

client.documents.createUploadSession(params, options?) returns APIPromise<UploadSession>. params is an UploadSessionParams:

ParameterTypeRequiredDescription
pathstringYesWhere the file goes in the workspace.
total_size_bytesnumberYesThe file's exact size across every part, at least 1.
ttl_secondsnumberNoHow long the session takes parts, 1 to 5400. Default: 5400.
on_conflictOnConflictNo"reject" (default) fails the assembly with code path_conflict when a different file has the path; "new_version" replaces it.
idempotency_keystringNoSent as Idempotency-Key: the same key returns the same session. Minted per call when omitted, so a retry never opens a second session.

UploadSession has workspace_id, session_id, session_token, chunk_size (every part's size, except a shorter last part), total_parts, expires_at and upload_session_url, the document service's URL for the session.

MethodReturnsDescription
documents.uploadPart(session, number, data, options?)Promise<void>Sends part number, from 0: chunk_size bytes of data (a FileContent), the last part shorter. Sending a part again replaces it.
documents.completeUploadSession(session, params?, options?)Promise<UploadedFile>Assembles the parts into one file. Waits up to 10 minutes under one params.idempotency_key (minted when omitted), then throws a NaceError naming the key: call again with it to keep waiting. A failed assembly, such as path_conflict, throws a NaceError with the job's error code.
documents.getUploadSession(session, options?)APIPromise<UploadSessionStatus>Which parts have landed, to resume after a drop.
documents.abortUploadSession(session, options?)Promise<void>Drops the session and its parts. Does nothing to a finished session. A session still assembling (completing) is refused with ConflictError, code session_busy: wait for completeUploadSession instead.

These four send X-Upload-Token: session.session_token to session.upload_session_url, never your API key, so they need only those two fields: save them to resume from another process. Their options take signal, timeout and retry, not headers.

A session refuses calls that don't fit its state. None of these is retried:

  • session_busy (ConflictError): sending a part or aborting while the session is completing, or completing it with a different key while a completion runs. Call completeUploadSession again with the same key to resume that completion.
  • upload_expired (ConflictError): sending a part to a session that completed or was aborted, or completing an aborted one. Completing a completed session again returns its file.
  • After expires_at the session token stops working, and every call on the session throws AuthenticationError (401). Open a new session.
UploadSessionStatus fieldTypeDescription
session_idstringThe session.
status"open" | "completing" | "completed" | "aborted"Where it stands, or another string a newer service sends.
chunk_size, total_partsnumberAs on UploadSession.
partsArray<{ part_number, size_bytes }>Parts landed so far, in no order. Send only the missing ones.
received_bytesnumberThe sum of parts[].size_bytes.
declared_size_bytesnumbertotal_size_bytes from the create.
expires_atstringWhen the session stops taking parts.
resume.ts
import { openAsBlob } from "node:fs";

const blob = await openAsBlob("media/interview.mp4");
const session = await client.documents.createUploadSession({
  path: "media/interview.mp4",
  total_size_bytes: blob.size,
});

// After a drop, ask which parts landed and send only the rest.
const status = await client.documents.getUploadSession(session);
const landed = new Set(status.parts.map((part) => part.part_number));
for (let number = 0; number < session.total_parts; number++) {
  if (landed.has(number)) continue;
  const start = number * session.chunk_size;
  await client.documents.uploadPart(session, number, blob.slice(start, start + session.chunk_size));
}
const file = await client.documents.completeUploadSession(session);

UploadedFile has the file's workspace_id, file_id and path, and the document service's other file fields, such as file_name, size_bytes and version.

Jobs

MethodReturnsDescription
jobs.get(jobId, options?)APIPromise<DocumentJob>One job, with its result once it has succeeded, until retention drops it. Classify may include a partial result while still running.
jobs.list(params?, options?)APIPromise<DocumentJobList>One page of jobs, newest first, without results.
jobs.iter(params?, options?)AsyncGenerator<DocumentJob>Every job across every page, 50 a page. params takes operation and status.
jobs.wait(jobId, opts?)Promise<DocumentJob>Polls until the job finishes. See Wait for a job.
jobs.events(jobId, opts?)AsyncGenerator<JobEvent>The job's progress as it happens. See Progress events.
jobs.delete(jobId, options?)Promise<void>Cancels a running job and drops it from the list. A finished job is just dropped. It stays readable by id, and work done is still charged.

A jobId that isn't a UUID throws a NaceError before sending. Another account's job answers 404 (NotFoundError), like one that doesn't exist. A job whose result has expired still reads: jobs.get and jobs.wait return it succeeded, with result set to null and result_state set to "expired" or "not_retained". See Jobs and results. A job created before 3 October 2026 that the document service no longer has answers 410 (NotFoundError, code job_unavailable).

jobs.list takes a ListParams:

ParameterTypeRequiredDescription
limitnumberNo1 to 50. Default: 20. Out of range throws a NaceError.
operationstringNoOnly one tool's jobs: one of DOCUMENT_OPERATIONS.
statusstringNoOnly jobs in one status: one of JOB_STATUSES. A job whose status changed since Drex recorded it is left off, so a page can be short, or empty, while next_cursor is set.
cursorstring | nullNoThe previous page's next_cursor. Keep going until it's null.
const page = await client.jobs.list({ limit: 20, operation: "parse", status: "succeeded" });
console.log(page.items.length, page.next_cursor);

for await (const job of client.jobs.iter({ operation: "extract" })) {
  console.log(job.job_id, job.status);
}

Wait for a job

client.jobs.wait(jobId, opts?) polls the job, 1 second apart at first and doubling to 10 seconds, until it's succeeded, failed or cancelled. Each poll is a jobs.get, with the client's retries.

ParameterTypeRequiredDescription
opts.timeoutnumberNoHow long to keep polling, in milliseconds: the whole wait, not one request. Default: 600000 (10 minutes).
opts.raiseOnFailurebooleanNoWhen true (default), a failed or cancelled job throws JobFailedError. When false, it's returned.
opts.signalAbortSignalNoStops waiting and throws APIUserAbortError.

When the time runs out it throws JobTimeoutError. The job keeps running, so you can wait again:

wait.ts
import { JobFailedError, JobTimeoutError } from "nace-sdk";

try {
  const done = await client.jobs.wait(jobId, { timeout: 120_000 });
  console.log(done.result);
} catch (err) {
  if (err instanceof JobTimeoutError) {
    console.log(`still ${err.job.status}; wait again with ${err.job.job_id}`);
  } else if (err instanceof JobFailedError) {
    console.log(err.job.error);
  } else {
    throw err;
  }
}

Progress events

client.jobs.events(jobId, opts?) yields a JobEvent each time the job's status or progress changes, for up to five minutes. The first one is the current state, and the stream ends once the job finishes. It's a latency convenience, never retried, and it counts against your per-minute limit: read the outcome with jobs.get or jobs.wait.

ParameterTypeRequiredDescription
opts.lastEventIdstringNoThe last event's String(event.sequence), to keep the numbering after a drop.
opts.signalAbortSignalNoAborts the stream. Before the response arrives it throws APIUserAbortError; while events are being read it throws the runtime's AbortError DOMException, or the signal's own reason.
let lastEventId: string | undefined;
for await (const event of client.jobs.events(jobId, { lastEventId })) {
  lastEventId = String(event.sequence);
  console.log(event.at, event.status, event.progress);
}
const done = await client.jobs.wait(jobId);

JobEvent has sequence, at (ISO 8601), status, progress (null until the job reports any) and message (usually null).

Job files

Links in a result to a job's own files point at https://console.nace.ai/v1/documents/jobs/{id}/...: figure crops, ground crops, transcripts, converted PDFs, cell maps, full content and spreadsheet rows. Each method below takes that link as it is, or the part after the job id, such as document-content/<ref>. A query or fragment on it is dropped. A full link from another job's result throws NaceError before any request: pass that job's id instead.

MethodReturnsDescription
jobs.fetchFile(jobId, path, options?)Promise<Response>Any job file, as a streaming Response.
jobs.download(jobId, path, options?)Promise<Uint8Array>Any job file, read into memory.
jobs.fileLink(jobId, path, options?)APIPromise<FileLink>A signed link to a stored file, good for 5 minutes.
jobs.getRequest(jobId, options?)APIPromise<JobRequest>The request the job ran under.
jobs.rows(jobId, path, params?, options?)APIPromise<RowsPage>One page of a parsed sheet's rows.

Downloads of job files don't count against your per-minute limit; the request echo and the event stream do. Results and their files expire on the document service's retention schedule. After that, job files and jobs.getRequest answer 404 or 410 (NotFoundError, code result_expired), while jobs.get still returns the job with result: null.

Download a file

jobs.fetchFile and jobs.download read every kind of job file. A stored file (figure crops, transcripts, ground crops, converted PDFs, cell maps) is fetched through its signed link, without your API key, or streams from Drex where the service can't sign links. Anything else, such as document-content, spreadsheet-content, spreadsheet-rows and request, streams from Drex.

Full content can still be in preparation: the SDK waits what Retry-After says (else the body's retry_after_seconds, else 3 seconds) and asks again. When waitTimeout runs out, it throws a NaceError; preparation keeps going, so ask again later. An export that failed throws ConflictError (409).

options is a FetchFileOptions: a RequestOptions plus waitTimeout, how long to wait for content in preparation, in milliseconds (default 600000). The signed-link fetch takes its signal, timeout and retry, not headers.

content.ts
import { createWriteStream } from "node:fs";
import { writeFile } from "node:fs/promises";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import type { ReadableStream } from "node:stream/web";

const job = await client.jobs.wait(jobId);
const { document } = job.result as { document: { content_url: string | null } };

if (document.content_url) {
  // Stream the complete Markdown to disk.
  const response = await client.jobs.fetchFile(jobId, document.content_url);
  await pipeline(Readable.fromWeb(response.body as ReadableStream), createWriteStream("report.md"));

  // Or read it into memory.
  await writeFile("report.md", await client.jobs.download(jobId, document.content_url));
}

jobs.fileLink returns a FileLink: url, a signed link that opens without your API key, and expires_at, in Unix seconds, 5 minutes away. Hand it to a browser to open or download. Only stored files have one: split-artifacts, ground-crops, ground-transcript, parse-images, parse-transcript, converted-pdf and spreadsheet-cell-maps. Any other path throws a NaceError saying to use fetchFile or download, before a request goes out. A stored-file path this job doesn't have rejects with NotFoundError (404). Where the deployment's document service can't sign links yet, stored files stream from Drex too and fileLink throws that error for them as well; fetchFile and download work either way.

const { url, expires_at } = await client.jobs.fileLink(jobId, "parse-images/<ref>");

Job request

jobs.getRequest returns a JobRequest: the request the job ran under, as the document service stored it, with request_type set to the job's kind, and its other fields, such as source, options, schema, classes or targets. A parse password is never stored. It expires with the job's result.

const request = await client.jobs.getRequest(jobId);
console.log(request.request_type, request.schema);

Spreadsheet rows

client.jobs.rows(jobId, path, params?, options?) reads one page of a parsed sheet's rows, in file order. path is the sheet's rows_url from the parse result, which only Parquet sources carry. params is a RowsParams:

ParameterTypeRequiredDescription
start_rownumberNoThe zero-based first row. Default: 0.
limitnumberNoRows per page, 1 to 100 (MAX_ROWS_PAGE_SIZE). Default: 50.

Out of range throws a NaceError. A RowsPage has path, start_row, limit, total_rows, columns (each a RowsColumn: name and data_type) and rows, each row's cells as text keyed by column name.

let start = 0;
while (true) {
  const page = await client.jobs.rows(jobId, rowsUrl, { start_row: start, limit: 100 });
  for (const row of page.rows) console.log(row);
  start += page.rows.length;
  if (page.rows.length === 0 || start >= page.total_rows) break;
}

Saved schemas

Save a JSON Schema once, then name it with schema_id on an extract instead of sending it each time. Schemas can't be edited or deleted; add a version instead. The schema routes are free and work at any balance.

MethodReturnsDescription
extractionSchemas.create(params, options?)APIPromise<ExtractionSchema>Saves version 1 of a new schema.
extractionSchemas.list(params?, options?)APIPromise<ExtractionSchemaList>One page of schemas at their latest version, sorted by schema_id (case-insensitively, with - and _ ignored at first; not byte order), without schema.
extractionSchemas.iter(params?, options?)AsyncGenerator<ExtractionSchema>Every schema, in list's order, 200 a page. params takes no filters.
extractionSchemas.get(schemaId, options?)APIPromise<ExtractionSchema>A schema's latest version.
extractionSchemas.createVersion(schemaId, params, options?)APIPromise<ExtractionSchema>Adds the next version, which becomes the latest.
extractionSchemas.listVersions(schemaId, params?, options?)APIPromise<ExtractionSchemaList>One page of a schema's versions, oldest first.
extractionSchemas.iterVersions(schemaId, params?, options?)AsyncGenerator<ExtractionSchema>Every version, oldest first, 200 a page.
extractionSchemas.getVersion(schemaId, version, options?)APIPromise<ExtractionSchema>One version.
Parameter typeFields
CreateExtractionSchemaParamsname (1 to 128 characters), schema (the JSON Schema), description? (up to 2000 characters).
CreateExtractionSchemaVersionParamsschema, and name? and description?, which keep their current values when left out.
ExtractionSchemaListParamslimit? (1 to 200, default 50; out of range throws a NaceError) and cursor?.
  • Retries: create and createVersion aren't idempotent, so they aren't retried unless you pass retry in options: a retried create can save a second schema.
  • Nace's schemas: the list can include schemas Nace provides, with owner: "platform". You can extract with them, but they take no new versions (404).
  • Ids: another account's schema_id answers 404. An empty id, or one with /, ? or #, throws a NaceError before sending.

An ExtractionSchema has schema_id, name, description, version, owner ("account" or "platform"), created_at, updated_at and schema, which list and iter leave out; version pages include it. An ExtractionSchemaList has items, next_cursor and, when the service counts them, total_count.

schemas.ts
const saved = await client.extractionSchemas.create({
  name: "invoice",
  description: "Invoice header fields",
  schema: {
    type: "object",
    properties: { invoice_number: { type: "string" }, total: { type: "number" } },
  },
});

const job = await client.documents.extract({
  source: "https://example.com/invoice.pdf",
  schema_id: saved.schema_id,
});

const v2 = await client.extractionSchemas.createVersion(saved.schema_id, {
  schema: {
    type: "object",
    properties: { invoice_number: { type: "string" }, total: { type: "number" }, due_date: { type: "string" } },
  },
});
await client.documents.extract({
  source: "https://example.com/invoice.pdf",
  schema_id: saved.schema_id,
  schema_version: v2.version,
});

for await (const version of client.extractionSchemas.iterVersions(saved.schema_id)) {
  console.log(version.version, version.name);
}

See Extract's saved schemas.

Read the raw response

Most methods return an APIPromise<T>. Await it for the parsed body, or call one of its methods:

MethodReturnsDescription
withResponse()Promise<WithResponse<T>>{ data, response, requestId }: the parsed body, the Response (its body already read) and the x-request-id header.
asResponse()Promise<Response>The Response with its body unread. Read the body yourself, and don't also await the parsed result. A non-2xx answer still rejects with an APIError.
map(fn)APIPromise<U>A new APIPromise of fn(data), sharing the response and its single parse.
const { data, response, requestId } = await client
  .systemOne({ state: "Refund my order", questions: { refund: noul("Is it a refund request?") } })
  .withResponse();

console.log(response.status, requestId, data.answers.refund.noul);

systemOne, models.list, the document creates, createUploadGrant, createUploadSession, getUploadSession, jobs.get, jobs.list, jobs.getRequest, jobs.rows, jobs.fileLink and the extractionSchemas reads and creates return an APIPromise. documents.upload, uploadPart, completeUploadSession, abortUploadSession, jobs.wait, jobs.delete, jobs.fetchFile and jobs.download return a plain Promise, and the iter, iterVersions and events methods are async generators.

Types and constants

client.models is a Models, client.documents a Documents, client.jobs a Jobs and client.extractionSchemas an ExtractionSchemas. The decision types are in Answer types and Request types, and the parameter types in each method's section above. The document types:

TypeWhat it is
DocumentJobA job as the document service sends it: job_id, kind (a DocumentOperation), status (a JobStatus), result, usually once it has succeeded (null again once retention drops it; Classify may include a partial one while still running, with reason_status), error, credits (final once usage_final is true), usage_final, created_at, and every other field the service sends, such as result_state: "available", or why a finished job's result is null ("expired" or "not_retained").
DocumentJobListitems (DocumentJob[]) and next_cursor, null on the last page.
DocumentOperation"parse" | "split" | "classify" | "extract" | "ground", or another string a newer service sends.
JobStatus"queued" | "running" | "succeeded" | "failed" | "cancelled", or another string a newer service sends.
JobEventOne event from jobs.events: sequence, at, status, progress, message.
JobRequestThe request echo from jobs.getRequest: request_type and the request's fields.
FileLinkurl and expires_at, in Unix seconds.
RowsPage, RowsColumnA page from jobs.rows, and one of its columns.
UploadedFileworkspace_id, file_id, path and the service's other file fields.
UploadGrant, UploadSession, UploadSessionStatusSee Uploads.
ExtractionSchema, ExtractionSchemaListSee Saved schemas.
OnConflict"reject" | "new_version".
FileContentBlob | Uint8Array | ArrayBuffer.
SourceInput, Source, UrlSource, WorkspaceFileSource, ParseResultSourceSee Sources.
ConstantValueDescription
DEFAULT_BASE_URL"https://console.nace.ai"The API root when neither baseURL nor NACE_BASE_URL is set.
DEFAULT_MODEL"drex-latest"The model when neither defaultModel nor NACE_DEFAULT_DECISION_MODEL is set.
DEFAULT_TIMEOUT_MS60000The default timeout.
ENV{ apiKey, baseURL, defaultModel, logLevel }The environment variable for each option, from "NACE_API_KEY" to "NACE_LOG_LEVEL". EnvVar is the union of the four names.
LEGACY_ENV{ NACE_API_KEY, NACE_BASE_URL, NACE_DEFAULT_DECISION_MODEL, NACE_LOG_LEVEL }Each variable's name before the rename, "DREX_API_KEY" to "DREX_LOG_LEVEL", read when the new one is unset or blank.
LOG_LEVELS["debug", "info", "warn", "error", "off"]Every LogLevel.
VERSION"0.1.0"The package version, sent as User-Agent and X-Nace-SDK: nace-sdk/<VERSION>.
DOCUMENT_OPERATIONS["parse", "split", "classify", "extract", "ground"]The five tools.
JOB_STATUSES["queued", "running", "succeeded", "failed", "cancelled"]Every job status.
TERMINAL_STATUSESReadonlySet of "succeeded", "failed", "cancelled"The statuses a job finishes in.
MAX_WAIT_SECONDS60The longest wait_seconds.
MAX_ROWS_PAGE_SIZE100The largest jobs.rows limit.
UPLOAD_SESSION_THRESHOLD_BYTES33554432 (32 MiB)The size from which upload sends a file in parts.
import { TERMINAL_STATUSES } from "nace-sdk";

const job = await client.jobs.get(jobId);
if (!TERMINAL_STATUSES.has(job.status)) console.log("still running");

Handle errors

Every error the SDK throws is a NaceError, except a failure while you read a streamed body (see below). An HTTP failure throws an APIError subclass:

StatusClass
400BadRequestError
401AuthenticationError
402InsufficientCreditError (error.type is insufficient_credit or payment_required)
403PermissionDeniedError
404, 410NotFoundError
409ConflictError
422UnprocessableEntityError
429RateLimitError
5xxInternalServerError (529 is OverloadedError, a subclass)
Any other, such as 413APIError

Every APIError has these fields:

FieldTypeDescription
statusnumberThe HTTP status.
headersHeadersThe response headers.
bodyunknownThe parsed JSON body, the text, or undefined when empty.
requestIdstring | undefinedx-request-id, else the body's request_id. Quote it in support requests.
typestring | undefinederror.type, the class of failure, such as rate_limit_error.
codestring | undefinederror.code, the exact cause, sent by the document routes.
issuesIssue[]error.issues: each place a /v1/systemone request failed validation, as { path, message }. Empty when there are none.
detailunknownerror.detail: the document service's detail behind a document-route error, such as the validation problems under detail.errors.
serverRetryableboolean | undefinederror.retryable, the server's verdict on retrying.

The other classes:

ClassThrown whenExtra fields
RateLimitError429.retryAfterMs: the server's delay from retry-after-ms or retry-after, or undefined.
APIConnectionErrorNo response arrived: DNS, TLS, a dropped connection or a cut-off body.
APITimeoutErrorAn attempt ran past its timeout. A kind of APIConnectionError.timeoutMs: the timeout that ran out.
APIUserAbortErrorYour signal aborted a request, a wait before a retry, or a wait between polls.
JobFailedErrorjobs.wait found the job failed or cancelled.job: the DocumentJob, with its error.
JobTimeoutErrorjobs.wait ran out of time. The job keeps running.job, and timeoutMs: the wait budget.
NaceErrorThe SDK refused your input before sending, the client configuration is invalid, or a call ended without the result it needs: a job file with no signed link (jobs.fileLink), content still in preparation after waitTimeout (jobs.fetchFile, jobs.download), an upload job that failed or is still assembling (documents.upload, completeUploadSession), or a response of an unexpected shape.

jobs.events, the Response from jobs.fetchFile and jobs.download read their body after the headers arrive. If the connection drops or your signal aborts while that body is read, you get the runtime's own error, a TypeError or an AbortError DOMException, rather than a NaceError.

errors.ts
import { APIError, NaceClient, InsufficientCreditError, noul, RateLimitError } from "nace-sdk";

const client = new NaceClient();

try {
  await client.systemOne({ state: "Cancel my plan", questions: { churn: noul("Does the customer want to cancel?") } });
} catch (err) {
  if (err instanceof InsufficientCreditError) {
    // insufficient_credit: top up or add a card; payment_required: pay the open invoice
    console.log(err.type, "- see Billing in the dashboard");
  } else if (err instanceof RateLimitError) {
    console.log("Retry in", err.retryAfterMs, "ms");
  } else if (err instanceof APIError) {
    console.log(err.status, err.type, err.issues, err.requestId);
  } else {
    throw err;
  }
}

See Errors and retries for what each error means, and the error reference for every status.

On the document routes, code names the exact cause, and detail holds the document service's detail when it refused the request, such as the validation problems under detail.errors. What each failure there throws:

What happenedWhat you get
A request the document service refused, such as an unsupported file type or an unknown fieldUnprocessableEntityError (422), with code (invalid_request, unsupported_file_type, invalid_source and so on), and detail when the document service sent one (undefined otherwise, as for unsupported_file_type).
The same idempotency_key with a different bodyConflictError (409), code idempotency_conflict.
A create while the balance isn't positive and no card pays for usage beyond it, or while the account has an unpaid invoiceInsufficientCreditError (402), with type insufficient_credit or payment_required. payment_required isn't retryable until the invoice is paid on the Billing page.
No such job or saved schema for this accountNotFoundError (404), code schema_not_found for a schema.
An upload session call that doesn't fit the session's stateConflictError (409), code session_busy or upload_expired. See Upload sessions.
Document prices aren't configured, or a retried key's job isn't confirmed yetInternalServerError (503), code document_billing_not_configured or job_pending.
A different file at a taken upload pathConflictError (409), code path_conflict, under 32 MiB; a NaceError naming the failed upload job and path_conflict from 32 MiB.
Full content whose export failedConflictError (409), from fetchFile or download.
An expired job file or request echo, or a job created before 3 October 2026 whose results are goneNotFoundError (410), code result_expired or job_unavailable. An expired job itself still reads, with result: null.
A job that failed, was cancelled, or outlasted the waitJobFailedError or JobTimeoutError, from jobs.wait.
Input the SDK checks before sendingNaceError: a string source that isn't https://, a URL without a usable file name, wait_seconds outside 0 to 60, an idempotency_key that is empty or over 200 characters, both or neither of schema and schema_id, schema_version without schema_id, a job id that isn't a UUID, a bad schema id, a limit or start_row out of range, a negative part number, or upload without a file_name.

The document routes have their own per-minute rate limit, separate from systemOne's. A create refused with 429 starts no job: retry it with the same idempotency_key. See Errors.

import { ConflictError, UnprocessableEntityError } from "nace-sdk";

try {
  await client.documents.upload(new Uint8Array([37, 80, 68, 70]), { file_name: "invoice.pdf" });
} catch (err) {
  if (err instanceof ConflictError && err.code === "path_conflict") {
    console.log("A different invoice.pdf is already there: pass on_conflict: \"new_version\"");
  } else if (err instanceof UnprocessableEntityError) {
    console.log(err.code, err.detail);
  } else {
    throw err;
  }
}

Retries

The client retries a failed attempt while its RetryPolicy allows:

FieldTypeDefaultDescription
maxRetriesnumber2Retries after the first attempt. 0 turns retries off.
backoffInitialMsnumber500The first wait, doubled for each retry up to backoffMaxMs.
backoffMaxMsnumber5000The longest backoff wait.
backoffJitternumber0.25The fraction of each backoff wait taken off at random, from 0 to 1.
httpStatusesReadonlySet<number>408, 429 and 500 to 599Statuses to retry. A set you pass replaces this one. An error whose body says "retryable": false is never retried.
respectRetryAfterbooleantrueWait what retry-after-ms or retry-after asks, instead of the backoff.
maxRetryAfterMsnumber60000The longest server delay to honor. A longer one falls back to the backoff.
apiConnectionErrorbooleantrueRetry an APIConnectionError.
apiTimeoutErrorbooleantrueRetry an APITimeoutError.
timeoutMsnumber | null30000The retry budget: no retry starts once the time since the call began, plus the next wait, would reach it. It doesn't cut a running attempt short. null removes it.

A value out of range throws a NaceError. A retry you pass to the client or to one call is merged over the policy beneath it, so name only the fields you change.

The budget. Before each retry, the client adds the time since the call started to the next wait. If that reaches timeoutMs, it stops and throws the last error. With the defaults, a 60-second attempt timeout and a 30-second budget:

  • An attempt that fails after 30 seconds or more is never retried. That covers every APITimeoutError and a 529 that Drex sends after waiting 55 seconds for the model.
  • A retry-after that doesn't fit in what is left of the 30 seconds isn't waited out, such as the 30 seconds a paused API asks for.

To retry those, give the budget room for every attempt and wait, or remove it:

// Three 60 s attempts and two waits of up to 60 s.
const client = new NaceClient({ retry: { timeoutMs: 300_000 } });

// Or no budget at all: maxRetries alone limits the call.
const patient = new NaceClient({ retry: { timeoutMs: null } });

A retry of a request to Drex carries X-Nace-Retry-Count. Document creates carry an Idempotency-Key, minted once per call, so their retries never start or bill a second job. extractionSchemas.create and createVersion aren't retried unless you pass retry, and jobs.events is never retried.

Logging

LevelWhat the SDK logs
"debug"Everything info logs, plus each request's URL, headers and JSON body, and each response's body.
"info"One line per attempt, with its status, time and request id, plus each retry, timeout, connection error and abort.
"warn", "error"Nothing yet: the SDK logs only at info and debug.
"off"Nothing.

LOG_LEVELS lists the levels from most to least verbose, ["debug", "info", "warn", "error", "off"], and LogLevel is their union. A Logger has debug, info, warn and error methods that take a message and any values, so console fits.

Logs are redacted: Authorization, Proxy-Authorization and X-API-Key keep only their last four characters, Cookie, Set-Cookie and X-Upload-Token are hidden, and so is a token or session_token field in a response body. Everything else is logged as it is at debug, including state, a parse password you send and signed link URLs, so keep debug out of shared logs.

const client = new NaceClient({ logLevel: "info", logger: console });

On this page