Perception

Uploads

Uploads put a file on the server, to use as a workspace_file source for the tools. 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, 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, 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 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_idstringThe workspace id the upload goes to.
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.
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.

Delete a file

client.documents.deleteFile(workspaceId, fileId, options?) deletes a file you uploaded: every version of it and everything derived from it. It can't be undone. It's free and works at any balance. It returns APIPromise<FileDeletion>.

ParameterTypeRequiredDescription
workspaceIdstringYesThe file's workspace_id, from UploadedFile. Only your account's workspace.
fileIdstringYesThe file's file_id, from UploadedFile.

The call waits while the deletion runs. Another account's workspace throws NotFoundError with code workspace_not_found, and a file that isn't there file_not_found.

FileDeletion fieldTypeDescription
object"file_deletion"Always file_deletion.
workspace_id, file_idstringThe file deleted.
status"deleted" | "deleting"deleted once every trace is confirmed gone; deleting if it is still running. Calling again then reports on the same deletion.
delete-file.ts
const file = await client.documents.upload(await readFile("invoice.pdf"), { file_name: "invoice.pdf" });
const deleted = await client.documents.deleteFile(file.workspace_id, file.file_id);
console.log(deleted.status); // "deleted"

On this page