# JavaScript SDK reference

This page lists the options and behaviour of each part of the [JavaScript and TypeScript SDK](/docs/sdk/javascript). Start with that page to install the package and moderate your first upload; use the guides it links for concepts shared by every SDK.

## Package

| Import                                                                                            | Contents                                                                                               |
| ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `calmlens`                                                                                        | Shared types, `CalmLensError`, `CALMLENS_ERROR_CODES`, `SDK_VERSION` and `getAssetUrl`                 |
| `calmlens/server`                                                                                 | `CalmLensServerClient`, `parseWebhookEvent`, `verifyWebhookSignature` and the webhook header constants |
| `calmlens/app`                                                                                    | `CalmLensAppClient` and `getAssetUrl`                                                                  |
| `calmlens/react`, `calmlens/react-native`, `calmlens/vue`, `calmlens/svelte`, `calmlens/elements` | The [upload widget and media component](/docs/widgets) for each UI library                             |
| `calmlens/ui`                                                                                     | The framework-free core the components share                                                           |
| `calmlens/file-icons`                                                                             | The file-type icons the upload widget draws                                                            |
| `calmlens/styles.css`                                                                             | The components' stylesheet, for Vue, Svelte and the custom elements                                    |
| `calmlens/testing`                                                                                | [Offline mocks and test helpers](/docs/sdk/javascript/testing)                                         |
| `calmlens/test-inputs`                                                                            | [Test input generators](/docs/sdk/javascript/testing#generate-test-inputs)                             |

The package ships ES modules and CommonJS with type declarations and loads no other packages at runtime. Importing only `verifyWebhookSignature` adds about 5 KB (minified) to a bundle, `CalmLensAppClient` about 19 KB and `CalmLensServerClient` about 28 KB. The main, `/server` and `/app` entry points never load the testing helpers. `react`, `vue` and `svelte` are optional peer dependencies: install the one you use.

Core model types (`Asset`, `Folder`, `Lens`, `AssetPage`, `EventsPage`, `ClassificationReport`) and request types are exported from the package root.

## Server client

```ts
import { CalmLensServerClient } from "calmlens/server";

const client = new CalmLensServerClient({
  apiKey: process.env.CALMLENS_API_KEY!,
  projectId: process.env.CALMLENS_PROJECT_ID!,
});
```

### Client options

| Option                    | Default                       | Meaning                                                                                                                               |
| ------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `apiKey`                  | Required                      | Your project API key.                                                                                                                 |
| `projectId`               | Required                      | Your project ID.                                                                                                                      |
| `baseUrl`                 | `https://api.calmlens.com/v1` | API base URL.                                                                                                                         |
| `cdnBaseUrl`              | `https://cdn.calmlens.com/v1` | CDN base URL.                                                                                                                         |
| `fetch`                   | The global `fetch`            | The `fetch` used for every request, for example one with a proxy or instrumentation.                                                  |
| `uploadFetch`, `cdnFetch` | `fetch`                       | Overrides for content uploads, or for `cdn.serve` only.                                                                               |
| `timeoutMs`               | `120000`                      | Deadline for each API request attempt, including reading the response body. For `cdn.serve` it ends when the response headers arrive. |
| `uploadTimeoutMs`         | None                          | Deadline for each binary upload attempt, so large files are never cut off. Text content uses `timeoutMs`.                             |
| `maxRetries`              | `2`                           | Retries for requests that are safe to repeat; `0` turns them off. See [Retries and idempotency](#retries-and-idempotency).            |
| `maxResponseBytes`        | 32 MiB                        | Largest JSON response the client reads.                                                                                               |
| `signal`                  | None                          | An `AbortSignal` that cancels every request this client makes.                                                                        |
| `dangerouslyAllowBrowser` | `false`                       | Lets the client run in a web page or app. Only for trusted internal tools: anyone who can open the page can read the API key.         |

Options are checked before anything else is set up. A missing `apiKey` or `projectId`, a `timeoutMs` that is not a positive integer, or a `baseUrl` or `cdnBaseUrl` that is not an absolute HTTP(S) URL throws `sdk/invalid-config`. The client keeps its API key out of `console.log`, `util.inspect` and `JSON.stringify`.

### Request budgets

`client.withRequestOptions({ signal, timeoutMs, uploadTimeoutMs, maxRetries })` returns a client that applies those options to one operation without changing the original. It shares the original's credentials and lens cache.

```ts
const scoped = client.withRequestOptions({ signal: request.signal, timeoutMs: 30_000 });
const asset = await scoped.assets.retrieve({ assetId });
```

Requests never follow redirects, so the API key is never sent to another origin. From server runtimes, requests send `User-Agent: calmlens-js/<version> (<runtime>/<version>)`; `SDK_VERSION` holds the version.

## Assets

### The asset object

Every asset method resolves to the stored `Asset`, a plain object with camelCase keys exactly as the API returns it:

```ts
const asset = await client.text.classify({ text: "Great photo!", externalId: "comment-42" });
asset.id; // string
asset.classification.status; // "pending" | "processing" | "completed" | "failed"
asset.classification.failureReason; // "file-too-large" | "processing-error" | ... | null
asset.classification.moderation.status; // "pending" | "allowed" | "limited" | "age-restricted" | "blocked" | "purged"
asset.classification.moderation.score; // the overall score
asset.classification.moderation.raw?.categories.harassment.status; // "evaluated" | "partial" | "not-evaluated"
asset.content.status; // "temporary" | "stored" | "removed"
asset.content.removalReason; // "policy" | "retention" | "requested" | "parent-removed" | "disposed" | null
asset.type; // "plaintext-document" for text
asset.createdAt; // milliseconds since the Unix epoch
```

`id`, `projectId`, `name`, `type`, `createdAt`, `updatedAt`, `upload`, `classification` and `content` are always present. `classification.moderation.raw` is absent until findings are available, and optional detections appear only when requested. The latest applied review in `classification.moderation.manualReviews` takes precedence over the automated decision. See [Asset lifecycle and moderation](/docs/guides/asset-lifecycle) for every status and failure reason, [Asset types and limits](/docs/guides/classifying-assets#asset-types-and-limits) for `type`, and [Reading classification results](/docs/guides/classification-results) for the report.

### Create and classify

`assets.create(options)` queues processing and returns the created asset at once. `assets.classify(options)` takes the same options and waits for classification. `text.classify({ text, ...options })` sends `text` as `text/plain` content named `text` unless you pass `name`.

| Option                                              | Meaning                                                                                                                                                                                                                                                        |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`                                              | Required. The asset's name.                                                                                                                                                                                                                                    |
| `content`                                           | Binary or plain-text content with its MIME type, such as `{ plainText: "hello", type: "text/plain" }` or `{ data: file, type: file.type }`. See [Content payloads](#content-payloads).                                                                         |
| `url`                                               | An external URL to fetch instead of sending content.                                                                                                                                                                                                           |
| `part`                                              | The first part of a [multipart upload](#multipart-uploads).                                                                                                                                                                                                    |
| `externalId`, `description`, `metadata`, `folderId` | Your ID, a description, metadata fields and the folder to store the asset in.                                                                                                                                                                                  |
| `visibility`                                        | `public` or `private`. Public retained content can be requested directly from the CDN; private content needs a signed URL or an authorised request.                                                                                                            |
| `uploadPresetId`                                    | Applies an [upload preset](/docs/guides/upload-presets)'s folder, visibility, file types and size limit.                                                                                                                                                       |
| `processingOptions.keepContentAfterProcessing`      | Keep the content after processing. By default it is removed once classification finishes.                                                                                                                                                                      |
| `processingOptions.storeSensitiveMetadata`          | Overrides the project setting for this upload. When `false`, embedded image metadata (EXIF, XMP and IPTC) is removed before the image is stored, and `content.fileMetadata` leaves out the creator and location. When `true`, the image is stored as uploaded. |
| `processingOptions.features`                        | Optional analyses, each turned on with `enabled: true`: such as `ocr`. See [Processing features](/docs/guides/classifying-assets#processing-features).                                                                                                         |
| `authorId`, `context`                               | The author and earlier posts of a reply. See [Replies in a conversation](#replies-in-a-conversation).                                                                                                                                                          |
| `idempotencyKey`                                    | Your own key for safe retries across processes. See [Retries and idempotency](#retries-and-idempotency).                                                                                                                                                       |

```ts
const document = await client.assets.create({
  name: "document.pdf",
  content: { base64: pdfBase64, type: "application/pdf" },
  description: "Important document",
  visibility: "private",
});

const page = await client.assets.create({
  name: "website-content",
  url: "https://example.com/page",
});
```

With OCR on, the image's own report includes the text pass, and the retained text is stored in the image's `content.extractedText`; no separate text child is created. Removing the image's content also removes the extracted text.

### Content payloads

`content` and multipart `part` accept one of:

| Form                                                   | Use it for                           |
| ------------------------------------------------------ | ------------------------------------ |
| `{ plainText: string }`                                | Text content only                    |
| `{ base64: string }`                                   | Base64-encoded bytes                 |
| `{ uint8Array: number[] \| Uint8Array }`               | Bytes                                |
| `{ buffer: Buffer \| ArrayBuffer }`                    | Bytes                                |
| `{ data: Blob \| File \| Response \| ReadableStream }` | Files, fetched responses and streams |

To upload a file from disk in Node.js without reading it into memory, pass `{ data: await openAsBlob(path), type }` (`openAsBlob` from `node:fs`). Unlike a stream, a `Blob` can be sent again if a retry is needed. The SDK sends the bytes as the raw request body, with upload details in the query string, never as multipart form data.

Use a concrete MIME type such as `image/png`, `application/pdf` or `text/plain`. The SDK rejects `application/octet-stream` and `multipart/*` types. Content is checked before it is sent, with the same codes the API uses, such as [`400 upload/invalid-payload`](/docs/api/assets#create-asset-error-upload-invalid-payload) and [`400 upload/invalid-mime-type`](/docs/api/assets#create-asset-error-upload-invalid-mime-type).

### Replies in a conversation

For a reply or comment in a thread, pass its `authorId` and the earlier posts in `context.items`, oldest first. Author IDs are your own opaque identifiers. The SDK sends only the posts CalmLens uses: the most recent 5 that fit in 2,000 characters, always including the most recent post.

```ts
const reply = await client.text.classify({
  text: "Same to you",
  authorId: "user-2",
  context: {
    items: [
      { text: "Has anyone tried the new update?", authorId: "user-1" },
      { text: "You would say that", authorId: "user-3" },
    ],
  },
});
reply.context; // the posts CalmLens used, or null once the content is removed
```

The same options work on `assets.create()` and `assets.classify()` for images, documents, audio and video. See [Threaded moderation](/docs/guides/threaded-moderation).

### Retrieve and wait for a decision

`assets.retrieve({ assetId })` fetches one asset.

`assets.waitForDecision({ assetId, timeoutMs?, signal? })` checks the asset every few seconds and returns it once `classification.moderation.status` is no longer `pending` or `classification.status` is `failed`. It rejects with `sdk/timeout` after `timeoutMs` (60000 by default), and with `sdk/cancelled` when `signal` aborts. Use it after `assets.create`, or with an asset ID you stored earlier; [webhooks](/docs/guides/webhooks) report decisions without polling.

```ts
const queued = await client.assets.create({ name: "photo.jpg", content: { data: blob, type: "image/jpeg" } });
const decided = await client.assets.waitForDecision({ assetId: queued.id, timeoutMs: 30_000 });
```

### List and iterate

`assets.list({ cursor?, pageSize?, ordering?, filter?, folderId? })` returns one page: `{ items, hasMore, nextCursor, pageSize }`. `ordering` is a list of `{ field: "createdAt" | "id", direction: "asc" | "desc" }`. To page by hand, pass `nextCursor` as `cursor` with the same filter and ordering until it is `null`.

`assets.iterate(options)` takes the same options and fetches pages as you go:

```ts
for await (const asset of client.assets.iterate({
  filter: "classification.moderation.status==blocked",
  pageSize: 100,
})) {
  console.log(asset.id);
}
```

Inserting or deleting other assets does not shift the cursor position. Lists are not snapshots: assets may still be deleted or change filter membership while you page. See [Pagination and filtering](/docs/guides/pagination-and-filtering) for filter expressions, and [Asset search](/docs/guides/search) for `assets.search`.

`assets.listChildren({ assetId, ... })` and `assets.iterateChildren({ assetId })` list the assets extracted from a website or document. See [Child assets](/docs/guides/asset-lifecycle#child-assets).

### Move and retry

`assets.move({ assetId, folderId })` moves an asset into a folder, or to the project root with `folderId: null`.

`assets.retry({ assetId })` processes a failed asset again. It returns in `processing` and is moderated like a new upload, so handle the result the same way. The content must still be stored; otherwise it throws [`409 asset/content-unavailable`](/docs/api/assets#retry-asset-error-asset-content-unavailable) and you need to upload it again.

### Explain a decision

To decide quickly, moderation can skip questions whose answers could not change any action, so a report with `classification.moderation.explanationComplete` set to `false` can credit a different rule, or show a different policy score, than a complete one. `assets.explain({ assetId })` answers those questions and returns the asset with `explanationComplete` set to `true`; every action stays as decided. See [Complete policy evidence](/docs/guides/classification-results#complete-policy-evidence).

It throws [`409 asset/not-decided`](/docs/api/assets#explain-asset-error-asset-not-decided) for an asset without a decision, and [`409 asset/policy-changed`](/docs/api/assets#explain-asset-error-asset-policy-changed) when the project's policy has changed since the decision. Pass `rerun: true` to moderate the asset again with the current policy instead: the asset returns in `processing` and can reach a different decision. A re-run throws [`409 asset/reviewed`](/docs/api/assets#explain-asset-error-asset-reviewed) for an asset a reviewer has decided.

### Record a reviewer's decision

`assets.decide({ assetId, decision, note, confirmPurge? })` replaces the policy's decision. `classification.moderation.status` becomes `decision`, the decision and its `note` (required, up to 1,499 characters) join `classification.moderation.manualReviews`, and the matching `asset.<status>` event is sent with a `decided-by: reviewer` fact. Purging needs `confirmPurge: true` and the `assets:delete` permission, and permanently deletes the stored content. See [`POST /assets/:assetId/decision`](/docs/api/assets#decide-asset).

```ts
await client.assets.decide({
  assetId,
  decision: "blocked",
  note: "Spam reported by three members",
});
```

| Error                                                                                              | When                                                                                                 |
| -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| [`409 asset/processing`](/docs/api/assets#decide-asset-error-asset-processing)                     | The asset is still uploading or processing.                                                          |
| [`409 asset/decision-not-allowed`](/docs/api/assets#decide-asset-error-asset-decision-not-allowed) | An asset pending moderation is allowed, limited or age-restricted; it can only be blocked or purged. |
| [`409 asset/decision-unchanged`](/docs/api/assets#decide-asset-error-asset-decision-unchanged)     | The asset already has that status.                                                                   |
| [`409 asset/content-purged`](/docs/api/assets#decide-asset-error-asset-content-purged)             | Purged content is given any decision but blocked.                                                    |

### Signed URLs

`assets.createSignedUrl({ assetId, resource?, lens?, transform?, viewerClaims?, expiresInSeconds? })` creates a short-lived URL for a private asset's `rendered` (default) or `original` resource. It expires after 900 seconds unless you set `expiresInSeconds`. Forward the URL unchanged. See [Signed URLs](/docs/guides/delivering-assets#signed-urls).

```ts
const signed = await client.assets.createSignedUrl({
  assetId,
  lens: "thumbnail",
  expiresInSeconds: 900,
});
```

## Multipart uploads

Start a [multipart upload](/docs/guides/multipart-uploads) by passing `part` to `assets.create` or `assets.classify`, then send later parts with `assets.uploadPart({ assetId, part })`. `partNumber` defaults to `1` and `isFinal` to `false` when you start an upload.

- An upload started with `create` is queued when its final part arrives; one started with `classify` waits for the decision. CalmLens saves the choice, so later parts can come from another process that knows only the asset ID. Pass `queue` to `uploadPart` to override it for the final part.
- Pass `totalSizeInBytes` next to `part` when you know the file's size, so CalmLens rejects an upload that could never finish before you send the rest.
- Every part but the final one must be at least 5 MiB. If you send base64, split the original bytes first, then encode each part; never split one base64 string.
- Direct image uploads must be smaller than 10 MiB. Multipart image uploads must total from 10 MiB to 20 MiB; CalmLens adds up the parts and rejects a final image outside that range.
- A `ReadableStream` part must include its exact `sizeInBytes`, so CalmLens can stream it without buffering.
- Retained multipart image originals stay in private storage; deliver them and their transforms through CDN URLs.

```ts
const uploading = await client.assets.create({
  name: "large-image.png",
  part: { uint8Array: firstPartBytes, type: "image/png" },
});

await client.assets.uploadPart({
  assetId: uploading.id,
  part: { base64: secondPartBase64, partNumber: 2 },
});

const completed = await client.assets.uploadPart({
  assetId: uploading.id,
  part: { base64: finalPartBase64, partNumber: 3, isFinal: true },
});
```

A single part with `isFinal: true` sent through `assets.classify` uploads and waits for the decision in one call.

## Folders

| Method                                                       | Behaviour                                                                                                                                                                                                                                                                                                                                                 |
| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `folders.list({ parentId?, cursor?, pageSize?, ordering? })` | One page of immediate child folders, of the project root when `parentId` is omitted. Returns `items`, the visible `ancestors` of the listed folders, `hasMore` and `nextCursor`. `pageSize` is 1 to 100 (default 50). Sorted by name, or by creation time with `ordering: [{ field: "createdAt", direction: "desc" }]` (`name` or `createdAt`, one rule). |
| `folders.retrieve({ folderId })`                             | One folder.                                                                                                                                                                                                                                                                                                                                               |
| `folders.create({ name, parentId? })`                        | A folder at the root or under `parentId`.                                                                                                                                                                                                                                                                                                                 |
| `folders.update({ folderId, name })`                         | Rename a folder.                                                                                                                                                                                                                                                                                                                                          |
| `folders.move({ folderId, parentId })`                       | Move a folder, or to the root with `parentId: null`. Moving it into itself or a descendant throws [`409 folder/parent-cycle`](/docs/api/folders#move-folder-error-folder-parent-cycle).                                                                                                                                                                   |
| `folders.delete({ folderId })`                               | Delete an empty folder. A folder with assets or child folders throws [`409 folder/not-empty`](/docs/api/folders#delete-folder-error-folder-not-empty).                                                                                                                                                                                                    |

## Lenses

`ensureLens(definition)` creates a project lens, or updates it safely. A lens can start at any positive version. Repeating the same version and definition is idempotent; any other update needs a greater version, and a stale or conflicting version throws [`409 lens/version-conflict`](/docs/api/lenses#ensure-lenses-error-lens-version-conflict). The client skips the request when it has already ensured the same definition.

```ts
const lens = await client.ensureLens({
  identifier: "picmagic-normal",
  name: "PicMagic normal",
  type: "image",
  active: true,
  version: 1,
  transforms: [{ type: "imageTransform", options: { fit: "scale-down", width: 1024, format: "png" } }],
});

const image = await client.cdn.serve({ assetIdentifier: assetId, lens: lens.identifier });
```

`ensureLenses({ lenses })` ensures several together: new definitions go in one request, or in consecutive requests of up to 100 each, in order. Each lens keeps its own versioning. The resolved lens version is part of the CDN's cache and ETag identity, so a new version replaces earlier derived output. See [Versioned custom lenses](/docs/guides/lenses#versioned-custom-lenses).

## CDN

`cdn.getAssetUrl(options)` builds a CDN URL without a request. `cdn.serve(options)` fetches it with the client's API key. Both take:

| Option            | Meaning                                                                                                                                                                                                                                                                                                         |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `assetIdentifier` | Required. The asset ID.                                                                                                                                                                                                                                                                                         |
| `resource`        | `rendered` (default, for display; takes lenses and transforms), `original` (the uploaded file) or `placeholder`.                                                                                                                                                                                                |
| `lens`            | A named lens. The SDK adds the `@` prefix the URL uses, such as `/rendered/@picmagic-normal`.                                                                                                                                                                                                                   |
| `transform`       | A [direct transform](/docs/guides/lenses#direct-transforms): structured steps such as `{ type: "imageTransform", options: { format: "png", width: 1024 } }`, an array of steps to chain, or a selector string with one path segment per step, such as `"width=1024,fit=cover/blur=12"`. It runs after the lens. |
| `viewerClaims`    | `{ region?, ageConfirmed? }`, see [Viewer claims](/docs/api/cdn#viewer-claims).                                                                                                                                                                                                                                 |

`assets.createSignedUrl` accepts the same `resource`, `lens`, `transform` and `viewerClaims`.

`cdn.serve` returns the `Response` as soon as headers arrive, so you can stream the body, for example `return new Response(response.body, response)`. Non-2xx responses are returned rather than thrown. Pass `init` for extra `fetch` options such as `method: "HEAD"`. It reads private assets when the key has the `assets:download` permission; give browsers and apps a signed URL instead.

The placeholder is a small blurred PNG made from the asset's ThumbHash, served publicly whatever the asset's visibility or moderation status, for example in place of an age-restricted image. It takes no `lens` or `transform`; passing one throws `sdk/invalid-argument`.

An `age-restricted` asset is delivered only when the request carries `viewerClaims: { ageConfirmed: true }`, even with a signed URL or API key; otherwise the CDN returns [`403 viewer/age-confirmation-required`](/docs/api/cdn#error-viewer-age-confirmation-required). Set it only where your app has decided the viewer may see age-restricted content, and leave it false in processing jobs. See [Age-restricted assets](/docs/guides/delivering-assets#age-restricted-assets) and [Viewer region](/docs/guides/jurisdiction-rules#viewer-region).

```ts
const restrictedUrl = client.cdn.getAssetUrl({
  assetIdentifier: assetId,
  viewerClaims: { ageConfirmed: true },
});
```

### URLs in the browser

`getAssetUrl`, exported from `calmlens/app` and `calmlens`, builds the same URLs from the project ID alone. It takes no API key and sends no request. Its options are `projectId`, `assetIdentifier`, `resource`, `lens`, `transform`, `viewerClaims` and `cdnBaseUrl` (default `https://cdn.calmlens.com/v1`).

```ts
import { getAssetUrl } from "calmlens/app";

const placeholder = getAssetUrl({ projectId, assetIdentifier: assetId, resource: "placeholder" });
```

## App client

`CalmLensAppClient` uploads from browsers and apps through an [upload preset](/docs/guides/upload-presets), without an API key.

```ts
import { CalmLensAppClient } from "calmlens/app";

const client = new CalmLensAppClient({
  uploadPresetId: "preset_4Lk8Qw2Xm9Rt5Vb3Nc7P",
  // Only for presets that require a token: called once per new upload
  token: () =>
    fetch("/api/upload-token", { method: "POST" })
      .then((response) => response.json())
      .then((body) => body.token),
});

const asset = await client.assets.classify({
  name: file.name,
  content: { data: file, type: file.type },
});
```

It takes `uploadPresetId` (required), `token` (a string, or a function that returns one or a promise of one) and the server client's `baseUrl`, `fetch`, `uploadFetch`, `timeoutMs`, `uploadTimeoutMs`, `maxRetries`, `maxResponseBytes` and `signal`. Passing `apiKey` throws `sdk/invalid-config`.

It has `assets.create`, `assets.classify`, `assets.uploadPart` and `assets.retrieve` for its own uploads, each returning an `UploadedAsset`. Its `status` holds the moderation status when the preset shows decisions to uploaders. The client fetches a new token for each upload and reuses it for that upload's parts and status checks.

In React Native and Expo apps, which send no `Origin` header, use a preset that requires a token and leave its allowed sites empty. Pass the picked file's bytes, for example `content: { buffer: await new File(uri).arrayBuffer(), type }` with `expo-file-system`. See the [Calmsnap example](https://github.com/CalmLens/sdks/tree/main/examples/mobile-uploads-expo).

### Upload tokens

On your server, `client.uploadTokens.sign({ uploadPresetId, externalId?, expiresInSeconds? })` signs a single-use token for one upload. Signing is local and makes no request. Tokens last 10 minutes by default and up to 3600 seconds. It resolves to `{ token, expiresAt }`, with `expiresAt` in milliseconds since the Unix epoch. See [Upload tokens](/docs/guides/upload-presets#upload-tokens).

## Webhooks

`parseWebhookEvent(options)` verifies a delivery and returns the parsed event. `verifyWebhookSignature(options)`, also a static method of `CalmLensServerClient`, takes the same options and returns a boolean.

| Option                   | Meaning                                                                                                                                                                                   |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `payload`                | Required. The raw request body as a string, `Uint8Array`, `Buffer` or `ArrayBuffer`. Parsed JSON throws `sdk/invalid-argument`, because serialising it again can change the signed bytes. |
| `secret`                 | Required. The connector's signing secret.                                                                                                                                                 |
| `headers`                | A `Headers` object or Node's `req.headers`, read when `signature` and `timestamp` are omitted.                                                                                            |
| `signature`, `timestamp` | The `x-calmlens-signature` and `x-calmlens-timestamp` header values.                                                                                                                      |
| `toleranceSeconds`       | Largest allowed clock difference, `WEBHOOK_SIGNATURE_TOLERANCE_SECONDS` (300) by default.                                                                                                 |
| `now`                    | The current time as a `Date` or milliseconds, for tests.                                                                                                                                  |

A delivery is valid when the signature header contains a `v1=` HMAC-SHA256 of `"<timestamp>.<raw body>"` (one match in a comma-separated list is enough) and the timestamp is within `toleranceSeconds` of now. `parseWebhookEvent` throws `sdk/invalid-webhook-signature` for a missing, wrong or stale signature and `sdk/invalid-webhook-payload` for a verified body that isn't a CalmLens event. The header names are exported as `WEBHOOK_DELIVERY_HEADER`, `WEBHOOK_TIMESTAMP_HEADER` and `WEBHOOK_SIGNATURE_HEADER`. See [Webhooks](/docs/guides/webhooks) for the payload and headers.

The event is typed as an extensible `WebhookEventPayload`: event types and fact keys may be ones your SDK version doesn't know, and fact values are `unknown`, so validate the facts your handler uses. `isTestWebhookEvent(event)` is `true` for a test send, which carries the `{ key: "test", value: true }` fact.

For Express, use `express.raw({ type: "application/json" })` on the route so `req.body` is the unparsed `Buffer`:

```ts
app.post("/api/webhooks/calmlens", express.raw({ type: "application/json" }), async (req, res) => {
  try {
    const event = await parseWebhookEvent({
      payload: req.body,
      headers: req.headers,
      secret: process.env.CALMLENS_WEBHOOK_SECRET!,
    });
    res.sendStatus(204);
  } catch {
    res.sendStatus(401);
  }
});
```

For Fastify, register a raw body parser for the route, such as `fastify.addContentTypeParser("application/json", { parseAs: "buffer" }, (_req, body, done) => done(null, body))`, then pass `payload: request.body` and `headers: request.headers`.

## Events feed

`events.list({ cursor?, pageSize? })` reads the project's events, oldest first, without a public webhook URL. It resolves to an `EventsPage` of `{ items, nextCursor, hasMore }`; each item has `id`, `projectId`, `type`, `severity`, `title`, `description`, `assetId`, `asset`, `facts` and `createdAt`, the same shape webhooks deliver.

- Without `cursor`, it returns no events and a `nextCursor` for the current time, so you see only events created after you start reading.
- With `cursor`, it returns up to `pageSize` events (1 to 100, default 50) created after that cursor. When there are none, `nextCursor` is the cursor you passed; it is never `null`.
- An unknown or malformed cursor fails with [`400 validation/invalid-cursor`](/docs/api/assets#list-assets-error-validation-invalid-cursor).

`events.pages({ cursor, pageSize })` follows the cursor until no more events are waiting. Save the latest `nextCursor` to resume after a restart. See [Reading the feed](/docs/api/events#reading-the-feed).

## Errors

Every failure is a `CalmLensError`, whether the API returned an error or the request never completed:

```ts
import { CALMLENS_ERROR_CODES, CalmLensError } from "calmlens";

try {
  await client.assets.retrieve({ assetId });
} catch (error) {
  if (!CalmLensError.isCalmLensError(error)) throw error;
  if (error.code === CALMLENS_ERROR_CODES.ASSET_NOT_FOUND) return undefined;
  console.error(error.code, error.status, error.requestId, error.details);
  throw error;
}
```

| Property     | Meaning                                                                                                                                                          |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `code`       | A stable identifier such as `asset/not-found` or `sdk/timeout`. `CALMLENS_ERROR_CODES` lists the documented ones.                                                |
| `status`     | The HTTP status, when the API responded.                                                                                                                         |
| `requestId`  | The `X-Request-Id` response header. Include it when you contact support.                                                                                         |
| `headers`    | The response headers, when the API responded.                                                                                                                    |
| `retryAfter` | Seconds to wait, from `Retry-After` (on `429` and [`409 idempotency/request-in-progress`](/docs/api/assets#create-asset-error-idempotency-request-in-progress)). |
| `details`    | Structured diagnostics from the API, such as validation issues.                                                                                                  |
| `cause`      | The underlying error, for transport failures.                                                                                                                    |

`error.isNotFound()` is `true` for `404`. `error.isRetryable()` is `true` for transport failures, timeouts (including `waitForDecision`'s deadline), `429`, `502`, `503`, `504` and [`409 idempotency/request-in-progress`](/docs/api/assets#create-asset-error-idempotency-request-in-progress). `instanceof CalmLensError` and `CalmLensError.isCalmLensError(error)` recognise errors from any copy of the SDK, including the ES module and CommonJS builds loaded side by side.

The SDK raises these codes itself:

| Code                                                           | Meaning                                                                                                               |
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `sdk/invalid-argument`                                         | A method argument is missing or invalid, such as an empty `assetId`.                                                  |
| `sdk/invalid-config`                                           | The client options are invalid.                                                                                       |
| `sdk/transport-error`                                          | The request could not complete: DNS, TLS, refused or reset connections, truncated bodies or decompression errors.     |
| `sdk/timeout`                                                  | A deadline passed.                                                                                                    |
| `sdk/cancelled`                                                | Your `AbortSignal` cancelled the request.                                                                             |
| `sdk/invalid-response`                                         | A successful response whose body is not the expected JSON, or a page that repeats a cursor the iterator already used. |
| `sdk/http-error`                                               | An HTTP failure without a CalmLens error body, such as from a proxy, or a redirect, which the SDK never follows.      |
| `sdk/response-too-large`                                       | A response larger than `maxResponseBytes`.                                                                            |
| `sdk/invalid-webhook-signature`, `sdk/invalid-webhook-payload` | Thrown by `parseWebhookEvent`.                                                                                        |

See [Error handling](/docs/guides/error-handling) for API error codes and recovery.

## Retries and idempotency

The client retries a request when repeating it is safe: reads, and creates and uploads, which always carry an `Idempotency-Key` header. It retries failures before any response, timeouts, `429`, `502`, `503`, `504` and [`409 idempotency/request-in-progress`](/docs/api/assets#create-asset-error-idempotency-request-in-progress), up to `maxRetries` times with jittered exponential backoff. It waits for `Retry-After` when the API sends one, up to 60 seconds; a longer wait is returned to you as the error's `retryAfter`. Other writes, such as `assets.move`, and uploads of a `ReadableStream`, which the first attempt consumed, are never retried.

`assets.create`, `assets.classify`, `text.classify` and `assets.uploadPart` generate a key for each call and reuse it for their own retries, so a retry returns the original response instead of creating a second asset. To retry safely across processes or restarts, pass your own:

```ts
await client.text.classify({
  text: comment.body,
  externalId: `comment-${comment.id}`,
  idempotencyKey: `comment-${comment.id}`,
});
```

The API keeps a response for 24 hours. Reusing a key for a different request fails with [`409 idempotency/key-reused`](/docs/api/assets#create-asset-error-idempotency-key-reused). See [Retrying requests safely](/docs/guides/error-handling#retrying-requests-safely).

## Forward compatibility

The SDK does not validate responses against its types, so fields and values added to the API later reach your code unchanged. `AssetClassificationStatus`, `AssetModerationStatus`, `AssetModerationDecision`, `AssetContentStatus`, `AssetContentRemovalReason`, `AssetType` and `AssetVisibility` include `string` for that reason: handle values you don't know, for example by treating an unknown status as not ready. Event types and fact keys are typed for the ones your SDK version knows; keep a default branch when you switch on them.
