JavaScript SDK reference
This page lists the options and behaviour of each part of the JavaScript and TypeScript SDK. 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 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 |
calmlens/test-inputs | Test input generators |
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
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. |
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.
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:
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 epochid, 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 for every status and failure reason, Asset types and limits for type, and Reading 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. |
url | An external URL to fetch instead of sending content. |
part | The first part of a multipart upload. |
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'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. |
authorId, context | The author and earlier posts of a reply. See Replies in a conversation. |
idempotencyKey | Your own key for safe retries across processes. See Retries and idempotency. |
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 and 400 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.
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 removedThe same options work on assets.create() and assets.classify() for images, documents, audio and video. See 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 report decisions without polling.
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:
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 for filter expressions, and Asset search for assets.search.
assets.listChildren({ assetId, ... }) and assets.iterateChildren({ assetId }) list the assets extracted from a website or document. See 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 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.
It throws 409 asset/not-decided for an asset without a decision, and 409 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 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 /.
await client.assets.decide({
assetId,
decision: "blocked",
note: "Spam reported by three members",
});| Error | When |
|---|---|
| 409 asset/processing | The asset is still uploading or processing. |
| 409 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 | The asset already has that status. |
| 409 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.
const signed = await client.assets.createSignedUrl({
assetId,
lens: "thumbnail",
expiresInSeconds: 900,
});Multipart uploads
Start a multipart upload 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
createis queued when its final part arrives; one started withclassifywaits for the decision. CalmLens saves the choice, so later parts can come from another process that knows only the asset ID. PassqueuetouploadPartto override it for the final part. - Pass
totalSizeInBytesnext topartwhen 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
ReadableStreampart must include its exactsizeInBytes, so CalmLens can stream it without buffering. - Retained multipart image originals stay in private storage; deliver them and their transforms through CDN URLs.
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. |
folders.delete({ folderId }) | Delete an empty folder. A folder with assets or child folders throws 409 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. The client skips the request when it has already ensured the same definition.
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.
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: 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. |
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. 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 and Viewer region.
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).
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, without an API key.
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 .
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.
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 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:
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 anextCursorfor the current time, so you see only events created after you start reading. - With
cursor, it returns up topageSizeevents (1 to 100, default 50) created after that cursor. When there are none,nextCursoris the cursor you passed; it is nevernull. - An unknown or malformed cursor fails with 400 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.
Errors
Every failure is a CalmLensError, whether the API returned an error or the request never completed:
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). |
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. 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 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, 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:
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. See 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.