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

ImportContents
calmlensShared types, CalmLensError, CALMLENS_ERROR_CODES, SDK_VERSION and getAssetUrl
calmlens/serverCalmLensServerClient, parseWebhookEvent, verifyWebhookSignature and the webhook header constants
calmlens/appCalmLensAppClient and getAssetUrl
calmlens/react, calmlens/react-native, calmlens/vue, calmlens/svelte, calmlens/elementsThe upload widget and media component for each UI library
calmlens/uiThe framework-free core the components share
calmlens/file-iconsThe file-type icons the upload widget draws
calmlens/styles.cssThe components' stylesheet, for Vue, Svelte and the custom elements
calmlens/testingOffline mocks and test helpers
calmlens/test-inputsTest 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

OptionDefaultMeaning
apiKeyRequiredYour project API key.
projectIdRequiredYour project ID.
baseUrlhttps://api.calmlens.com/v1API base URL.
cdnBaseUrlhttps://cdn.calmlens.com/v1CDN base URL.
fetchThe global fetchThe fetch used for every request, for example one with a proxy or instrumentation.
uploadFetch, cdnFetchfetchOverrides for content uploads, or for cdn.serve only.
timeoutMs120000Deadline for each API request attempt, including reading the response body. For cdn.serve it ends when the response headers arrive.
uploadTimeoutMsNoneDeadline for each binary upload attempt, so large files are never cut off. Text content uses timeoutMs.
maxRetries2Retries for requests that are safe to repeat; 0 turns them off. See Retries and idempotency.
maxResponseBytes32 MiBLargest JSON response the client reads.
signalNoneAn AbortSignal that cancels every request this client makes.
dangerouslyAllowBrowserfalseLets 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 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 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.

OptionMeaning
nameRequired. The asset's name.
contentBinary or plain-text content with its MIME type, such as { plainText: "hello", type: "text/plain" } or { data: file, type: file.type }. See Content payloads.
urlAn external URL to fetch instead of sending content.
partThe first part of a multipart upload.
externalId, description, metadata, folderIdYour ID, a description, metadata fields and the folder to store the asset in.
visibilitypublic or private. Public retained content can be requested directly from the CDN; private content needs a signed URL or an authorised request.
uploadPresetIdApplies an upload preset's folder, visibility, file types and size limit.
processingOptions.keepContentAfterProcessingKeep the content after processing. By default it is removed once classification finishes.
processingOptions.storeSensitiveMetadataOverrides 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.featuresOptional analyses, each turned on with enabled: true: such as ocr. See Processing features.
authorId, contextThe author and earlier posts of a reply. See Replies in a conversation.
idempotencyKeyYour 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:

FormUse 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 removed

The 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 /assets/:assetId/decision.

await client.assets.decide({
  assetId,
  decision: "blocked",
  note: "Spam reported by three members",
});
ErrorWhen
409 asset/processingThe asset is still uploading or processing.
409 asset/decision-not-allowedAn asset pending moderation is allowed, limited or age-restricted; it can only be blocked or purged.
409 asset/decision-unchangedThe asset already has that status.
409 asset/content-purgedPurged 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 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.
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

MethodBehaviour
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:

OptionMeaning
assetIdentifierRequired. The asset ID.
resourcerendered (default, for display; takes lenses and transforms), original (the uploaded file) or placeholder.
lensA named lens. The SDK adds the @ prefix the URL uses, such as /rendered/@picmagic-normal.
transformA 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.

OptionMeaning
payloadRequired. 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.
secretRequired. The connector's signing secret.
headersA Headers object or Node's req.headers, read when signature and timestamp are omitted.
signature, timestampThe x-calmlens-signature and x-calmlens-timestamp header values.
toleranceSecondsLargest allowed clock difference, WEBHOOK_SIGNATURE_TOLERANCE_SECONDS (300) by default.
nowThe 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 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.

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;
}
PropertyMeaning
codeA stable identifier such as asset/not-found or sdk/timeout. CALMLENS_ERROR_CODES lists the documented ones.
statusThe HTTP status, when the API responded.
requestIdThe X-Request-Id response header. Include it when you contact support.
headersThe response headers, when the API responded.
retryAfterSeconds to wait, from Retry-After (on 429 and 409 idempotency/request-in-progress).
detailsStructured diagnostics from the API, such as validation issues.
causeThe 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:

CodeMeaning
sdk/invalid-argumentA method argument is missing or invalid, such as an empty assetId.
sdk/invalid-configThe client options are invalid.
sdk/transport-errorThe request could not complete: DNS, TLS, refused or reset connections, truncated bodies or decompression errors.
sdk/timeoutA deadline passed.
sdk/cancelledYour AbortSignal cancelled the request.
sdk/invalid-responseA successful response whose body is not the expected JSON, or a page that repeats a cursor the iterator already used.
sdk/http-errorAn HTTP failure without a CalmLens error body, such as from a proxy, or a redirect, which the SDK never follows.
sdk/response-too-largeA response larger than maxResponseBytes.
sdk/invalid-webhook-signature, sdk/invalid-webhook-payloadThrown 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.

Last updated

Signing in

Signing out

Cookies on CalmLens

We use essential cookies for sign-in and security. With your permission, functional cookies remember your preferences and Google Analytics measures website use and signups. Optional ad measurement cookies link Google ad clicks to signups. Personalised advertising stays off. Manage cookies