@calmlens/sdk 0.0.0-stage → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,655 @@
1
- # Temporary Holding Version
1
+ # @calmlens/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Official JavaScript/TypeScript SDK for CalmLens API
4
+
5
+ CalmLens provides content moderation and classification for text, images, documents and websites, plus optional storage and CDN delivery. This SDK makes it easy to integrate CalmLens into your JavaScript/TypeScript applications.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @calmlens/sdk
11
+ ```
12
+
13
+ Requires Node.js 22 or later (the minimum its dependencies support), or another runtime with `fetch`, Web Crypto and `AbortSignal.any`, such as Bun or Deno.
14
+
15
+ Every public SDK operation accepts one options object. This keeps call sites stable as optional fields are added.
16
+
17
+ ## Quick Start
18
+
19
+ Create an API key under **Developer → API Keys** in the CalmLens portal. The same page shows your **Project ID**. Keep both on your server.
20
+
21
+ ```typescript
22
+ import { CalmLensClient } from "@calmlens/sdk";
23
+
24
+ const client = new CalmLensClient({
25
+ apiKey: process.env.CALMLENS_API_KEY!,
26
+ projectId: process.env.CALMLENS_PROJECT_ID!,
27
+ });
28
+
29
+ // Text (comments, captions, bios): waits for the decision
30
+ const comment = await client.text.moderate({ text: "Great photo!", externalId: "comment-42" });
31
+
32
+ if (comment.status === "approved") {
33
+ // publish it; if comment.report?.flagged is true, also queue it for review
34
+ } else {
35
+ // "rejected": don't publish it
36
+ }
37
+
38
+ // Images and documents: queued, resolved by webhook or assets.retrieve()
39
+ const image = await client.assets.create({
40
+ name: "photo.jpg",
41
+ content: { data: file, type: "image/jpeg" },
42
+ externalId: "post-7",
43
+ });
44
+ console.log(image.status); // "pending"
45
+ ```
46
+
47
+ | `status` | `report.flagged` | Meaning |
48
+ | --- | --- | --- |
49
+ | `approved` | `false` | Safe to show. |
50
+ | `approved` | `true` | Show it, but your policy asked to be alerted. |
51
+ | `rejected` | `true` | Your policy hid or purged it. Don't show it. |
52
+ | `pending` | — | Still being classified. |
53
+ | `error` | — | Processing failed. |
54
+
55
+ See [How CalmLens works](https://calmlens.com/docs/how-it-works) and [Testing your integration](https://calmlens.com/docs/guides/testing) for test inputs that trigger rejections safely.
56
+
57
+ ## API Reference
58
+
59
+ ### CalmLensClient
60
+
61
+ The main client class for interacting with the CalmLens API.
62
+
63
+ #### Constructor
64
+
65
+ ```typescript
66
+ new CalmLensClient(options: CalmLensClientOptions)
67
+ ```
68
+
69
+ **Options:**
70
+ - `apiKey` (string, required): Your CalmLens API key
71
+ - `projectId` (string, required): Your CalmLens project ID
72
+ - `baseUrl` (string, optional): API base URL (defaults to `https://api.calmlens.com/v1`)
73
+ - `cdnBaseUrl` (string, optional): CDN base URL (defaults to `https://cdn.calmlens.com/v1`)
74
+ - `timeoutMs` (positive integer, optional): Deadline for each request, including reading the response body (defaults to `120000`)
75
+ - `signal` (AbortSignal, optional): Cancels every request made by this client
76
+ - `uploadFetch`, `cdnFetch` (optional): `fetch` implementations for content uploads and CDN requests
77
+ - `requestBackend` (optional): An `api-def` request backend for the JSON API, for example `new FetchRequestBackend(customFetch)`
78
+
79
+ Options are validated before anything else is set up: a missing `apiKey` or `projectId`, a `timeoutMs` that is not a positive integer, or a `baseUrl`/`cdnBaseUrl` that is not an absolute `http(s)` URL throws a `CalmLensError`.
80
+
81
+ #### Methods
82
+
83
+ ##### assets.create(options)
84
+
85
+ Queue an asset for processing and return the created asset immediately.
86
+
87
+ ```typescript
88
+ async assets.create(options: SubmitAssetOptions): Promise<Asset>
89
+ ```
90
+
91
+ **Options:**
92
+ - `name` (string, required): Name of the asset
93
+ - `content` (optional): Binary or plain-text content and its concrete MIME type, e.g. `{ plainText: "hello", type: "text/plain" }` or `{ data: file, type: file.type }`
94
+ - `url` (string, optional): External URL to fetch instead of sending content
95
+ - `part` (optional): First part of a sequential multipart upload
96
+ - `description` (string, optional): Description of the asset
97
+ - `processingOptions.keepContentAfterProcessing` (boolean, optional): Retain content after processing
98
+ - `visibility` (string, optional): "public" or "private"
99
+
100
+ **Example:**
101
+ ```typescript
102
+ // Submit from file content
103
+ const asset = await client.assets.create({
104
+ name: "document.pdf",
105
+ content: {
106
+ base64: "JVBERi0xLjQKJcfs...",
107
+ type: "application/pdf",
108
+ },
109
+ description: "Important document",
110
+ visibility: "private"
111
+ });
112
+
113
+ // Submit from URL
114
+ const asset = await client.assets.create({
115
+ name: "website-content",
116
+ url: "https://example.com/page",
117
+ description: "Web page to moderate"
118
+ });
119
+ ```
120
+
121
+ ##### assets.moderate(options) and text.moderate(options)
122
+
123
+ Submit an asset and wait for classification to finish. Accepts the same options as `assets.create()` for text, images, documents, and URLs. `text.moderate({ text, name?, externalId?, ... })` is a shortcut that sends `text` as `text/plain` content named `text` by default.
124
+
125
+ ```typescript
126
+ async assets.moderate(options: SubmitAssetOptions): Promise<Asset>
127
+ async text.moderate(options: ModerateTextOptions): Promise<Asset>
128
+ ```
129
+
130
+ Both resolve to the stored `Asset`, a plain object with camelCase keys exactly as the API returns it:
131
+
132
+ ```typescript
133
+ const asset = await client.text.moderate({ text: "Great photo!", externalId: "comment-42" });
134
+ asset.id; // string
135
+ asset.status; // "approved" | "rejected" | "error"; "pending" for audio and video, which always run in the background
136
+ asset.type; // "plaintext-document" for text
137
+ asset.report?.flagged; // boolean
138
+ asset.report?.score; // number, the overall score
139
+ asset.report?.categories.harassment.status; // "evaluated" | "partial" | "not-evaluated"
140
+ asset.moderationAction; // the policy action applied, if any
141
+ asset.createdAt; // milliseconds since the Unix epoch
142
+ ```
143
+
144
+ Always present: `id`, `projectId`, `name`, `type`, `status`, `createdAt` and `updatedAt`. Every other field may be `null` or missing: `report` is absent when processing failed (`status: "error"`) or has not finished, `moderationAction` when no policy action applied, `externalId`, `description`, `metadata`, `folderId`, `visibility` and `processingOptions` when you did not set them, and `sizeInBytes`, `fileFormat`, `imageData`, `documentData`, `previewImage` and `extractedText` when they do not apply to the content. Inside a report, a category's `score` and `likelihood` exist only when its `status` is `evaluated` or `partial` (see [Moderation Reports](#moderation-reports)).
145
+
146
+ ##### assets.retrieve(options)
147
+
148
+ Retrieve details of a specific asset.
149
+
150
+ ```typescript
151
+ async assets.retrieve(options: { assetId: string }): Promise<Asset>
152
+ ```
153
+
154
+ **Example:**
155
+ ```typescript
156
+ const asset = await client.assets.retrieve({ assetId: "asset-uuid-here" });
157
+ console.log(asset.status);
158
+ console.log(asset.report); // Classification results
159
+ ```
160
+
161
+ ##### ensureLens(options)
162
+
163
+ Create a project-scoped named lens, or update it safely. A lens can start at any positive version. Repeating the exact same version and definition is idempotent; any other update must use a version greater than the current one. A stale or conflicting version throws `lens/version-conflict`.
164
+
165
+ ```typescript
166
+ const lens = await client.ensureLens({
167
+ identifier: "picmagic-normal",
168
+ name: "PicMagic normal",
169
+ type: "image",
170
+ active: true,
171
+ version: 1,
172
+ transforms: [
173
+ {
174
+ type: "imageTransform",
175
+ options: { fit: "scale-down", width: 1024, format: "png" },
176
+ },
177
+ ],
178
+ });
179
+
180
+ const image = await client.cdn.serve({
181
+ assetIdentifier: assetId,
182
+ lens: lens.identifier,
183
+ });
184
+ ```
185
+
186
+ For several lenses, use `ensureLenses()` to perform all ensures in one request. Each lens keeps the same independent versioning rules.
187
+
188
+ ```typescript
189
+ const [normal, uhd] = await client.ensureLenses({ lenses: [normalLens, uhdLens] });
190
+ ```
191
+
192
+ The CDN uses named lenses with an `@` prefix in URLs, such as `/content/@picmagic-normal`. The SDK adds it automatically when you supply `lens`. Use `transform` for one-off direct transforms, for example `client.cdn.serve({ assetIdentifier: assetId, transform: "imageTransform[format=png,width=1024]" })`. Supply either `lens` or `transform`, not both. The resolved lens version is part of CalmLens's CDN cache and ETag identity, so changing a lens with the next version invalidates prior derived output.
193
+
194
+ ##### assets.list(options)
195
+
196
+ List assets in your project with pagination.
197
+
198
+ ```typescript
199
+ async assets.list(options: {
200
+ pageIndex?: number;
201
+ pageSize?: number;
202
+ ordering?: Array<{ field: string; direction: "ASC" | "DESC" }>;
203
+ filter?: string | null;
204
+ }): Promise<{
205
+ items: Asset[];
206
+ total: number;
207
+ hasMore: boolean;
208
+ pageIndex: number;
209
+ pageSize: number;
210
+ }>
211
+ ```
212
+
213
+ **Example:**
214
+ ```typescript
215
+ const result = await client.assets.list({
216
+ pageIndex: 0,
217
+ pageSize: 20
218
+ });
219
+
220
+ console.log(`Found ${result.total} assets`);
221
+ result.items.forEach(asset => {
222
+ console.log(`${asset.name}: ${asset.status}`);
223
+ });
224
+ ```
225
+
226
+ ##### assets.listChildren(options)
227
+
228
+ List assets extracted from a website or multimedia document.
229
+
230
+ ```typescript
231
+ const children = await client.assets.listChildren({
232
+ assetId: parentAssetId,
233
+ pageIndex: 0,
234
+ pageSize: 20,
235
+ });
236
+ ```
237
+
238
+ ##### assets.createSignedUrl(options)
239
+
240
+ Create a short-lived URL for private CDN content or previews.
241
+
242
+ ```typescript
243
+ const signed = await client.assets.createSignedUrl({
244
+ assetId,
245
+ resource: "preview",
246
+ lens: "thumbnail",
247
+ expiresInSeconds: 900,
248
+ });
249
+ ```
250
+
251
+ ##### assets.move(options)
252
+
253
+ Move an asset into a folder, or to the project root by passing `null`.
254
+
255
+ ```typescript
256
+ // Move asset to a folder
257
+ await client.assets.move({
258
+ assetId: "asset-uuid",
259
+ folderId: "folder-uuid",
260
+ });
261
+
262
+ // Move asset to project root
263
+ await client.assets.move({
264
+ assetId: "asset-uuid",
265
+ folderId: null,
266
+ });
267
+ ```
268
+
269
+ ##### assets.retry(options)
270
+
271
+ Process an asset that failed (status `error`) again. It returns to `pending` and is moderated like a
272
+ new upload, so handle the result the same way (webhook or polling). Its content must still be stored:
273
+ otherwise this throws `asset/content-unavailable`, and you need to upload it again.
274
+
275
+ ```typescript
276
+ const asset = await client.assets.retry({ assetId: "asset-uuid" });
277
+ // asset.status === "pending"
278
+ ```
279
+
280
+ ##### folders.list()
281
+
282
+ List all folders in the current project.
283
+
284
+ ```typescript
285
+ const folders = await client.folders.list();
286
+ ```
287
+
288
+ ##### folders.retrieve(options)
289
+
290
+ Retrieve details of a specific folder by ID.
291
+
292
+ ```typescript
293
+ const folder = await client.folders.retrieve({ folderId: "folder-uuid" });
294
+ ```
295
+
296
+ ##### folders.create(options)
297
+
298
+ Create a folder at the project root or nested under another folder.
299
+
300
+ ```typescript
301
+ // Create root folder
302
+ const marketing = await client.folders.create({ name: "Marketing" });
303
+
304
+ // Create nested subfolder
305
+ const launch = await client.folders.create({
306
+ name: "Launch 2026",
307
+ parentId: marketing.id,
308
+ });
309
+ ```
310
+
311
+ ##### folders.update(options)
312
+
313
+ Rename an existing folder.
314
+
315
+ ```typescript
316
+ const updated = await client.folders.update({
317
+ folderId: "folder-uuid",
318
+ name: "New Name",
319
+ });
320
+ ```
321
+
322
+ ##### folders.move(options)
323
+
324
+ Move a folder under another folder, or to the project root by passing `parentId: null`. Moving a folder into itself or a descendant throws `folder/parent-cycle`.
325
+
326
+ ```typescript
327
+ // Move folder to another parent
328
+ await client.folders.move({
329
+ folderId: child.id,
330
+ parentId: anotherParent.id,
331
+ });
332
+
333
+ // Move folder to project root
334
+ await client.folders.move({
335
+ folderId: child.id,
336
+ parentId: null,
337
+ });
338
+ ```
339
+
340
+ ##### folders.delete(options)
341
+
342
+ Delete an empty folder. If the folder still contains assets or child folders, this throws `folder/not-empty`.
343
+
344
+ ```typescript
345
+ await client.folders.delete({ folderId: "folder-uuid" });
346
+ ```
347
+
348
+ ##### cdn.getAssetUrl(options) and cdn.serve(options)
349
+
350
+ `getAssetUrl` constructs a CDN URL without fetching it. `serve` fetches the resource and sends the client's API key.
351
+
352
+ ```typescript
353
+ const url = client.cdn.getAssetUrl({
354
+ assetIdentifier: assetId,
355
+ resource: "content",
356
+ lens: "thumbnail",
357
+ });
358
+
359
+ const response = await client.cdn.serve({
360
+ assetIdentifier: assetId,
361
+ resource: "content",
362
+ });
363
+ ```
364
+
365
+ Private CDN content cannot be read directly with API keys. Use a short-lived signed URL for application clients.
366
+
367
+ ##### Multipart upload flow
368
+
369
+ Use `create` with `part` to start multipart uploads, and `uploadPart` for subsequent chunks. `partNumber` defaults to `1` and `isFinal` defaults to `false` when starting an upload.
370
+
371
+ Image upload sizes use distinct direct and multipart paths:
372
+
373
+ - Direct image uploads must be smaller than `10MB`.
374
+ - Multipart image uploads must total between `10MB` and `20MB`, inclusive. CalmLens accumulates the uploaded parts and rejects a final image outside that range; callers do not need to provide a total-size field.
375
+ - Retained multipart image originals remain in private backing storage. Public or authorized delivery and transformations use CalmLens CDN URLs.
376
+
377
+ Each non-final multipart part must be at least `5MiB`; only the final part may be smaller. If you send base64, split the original bytes first, then base64-encode each part. Do not split one full base64 string into substrings.
378
+
379
+ Payloads can be sent as `{ plainText: string }` (text content only), `{ base64: string }`, `{ uint8Array: number[] | Uint8Array }`, `{ buffer: Buffer | ArrayBuffer }`, `{ data: Blob | Response | ReadableStream }`. Use `{ data: file }` for `File` objects. Upload metadata is URL query data and the SDK sends the binary payload as the raw request body without constructing multipart form data. Multipart `ReadableStream` uploads must include the exact `sizeInBytes` so CalmLens can stream each part without buffering it.
380
+
381
+ Use a concrete asset MIME type such as `image/png`, `application/pdf`, or `text/plain`. The SDK rejects ambiguous/container request MIME types such as `application/octet-stream` and `multipart/*`.
382
+
383
+ **Example (queued finalization):**
384
+ ```typescript
385
+ const uploading = await client.assets.create({
386
+ name: "large-image.png",
387
+ part: {
388
+ uint8Array: firstPartBytes,
389
+ type: "image/png"
390
+ }
391
+ });
392
+
393
+ await client.assets.uploadPart({
394
+ assetId: uploading.id,
395
+ part: {
396
+ base64: secondPartBase64,
397
+ partNumber: 2,
398
+ },
399
+ });
400
+
401
+ const completed = await client.assets.uploadPart({
402
+ assetId: uploading.id,
403
+ part: {
404
+ base64: finalPartBase64,
405
+ partNumber: 3,
406
+ isFinal: true,
407
+ },
408
+ });
409
+ ```
410
+
411
+ **Example (single-call synchronous analysis):**
412
+ ```typescript
413
+ const analyzed = await client.assets.moderate({
414
+ name: "large-image.png",
415
+ part: {
416
+ base64: finalPartBase64,
417
+ type: "image/png",
418
+ isFinal: true
419
+ }
420
+ });
421
+ console.log(analyzed.status);
422
+ ```
423
+
424
+ #### Static Methods
425
+
426
+ ##### verifyWebhookSignature(options)
427
+
428
+ Verify that a webhook delivery came from CalmLens and is fresh. Also exported as a standalone `verifyWebhookSignature` function.
429
+
430
+ ```typescript
431
+ static async verifyWebhookSignature(options: {
432
+ payload: string | object; // raw request body (pass the string, not parsed JSON)
433
+ signature: string | null | undefined; // x-calmlens-signature header
434
+ timestamp: string | number | null | undefined; // x-calmlens-timestamp header
435
+ secret: string;
436
+ toleranceSeconds?: number; // default 300
437
+ now?: Date | number; // default Date.now(), for tests
438
+ }): Promise<boolean>
439
+ ```
440
+
441
+ It returns `true` only when the `x-calmlens-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.
442
+
443
+ **Example:**
444
+ ```typescript
445
+ // In your webhook handler (Fetch API Request)
446
+ const payload = await request.text();
447
+ const isValid = await CalmLensClient.verifyWebhookSignature({
448
+ payload,
449
+ signature: request.headers.get('x-calmlens-signature'),
450
+ timestamp: request.headers.get('x-calmlens-timestamp'),
451
+ secret: WEBHOOK_SECRET,
452
+ });
453
+
454
+ if (!isValid) {
455
+ return new Response('Unauthorized', { status: 401 });
456
+ }
457
+ const event: WebhookEventPayload = JSON.parse(payload);
458
+ ```
459
+
460
+ ## Asset Types
461
+
462
+ Assets can be of the following types:
463
+ - `image` - Images (JPEG, PNG, GIF, WebP, etc.)
464
+ - `video` - Video files (MP4, WebM, etc.)
465
+ - `audio` - Audio files (MP3, WAV, etc.)
466
+ - `plaintext-document` - Plain-text formats such as TXT, JSON, CSV, and YAML
467
+ - `multimedia-document` - Documents such as PDF, DOCX, DOC, RTF, and ODT
468
+ - `website` - Web pages and URLs
469
+ - `file` - Other supported binary files
470
+
471
+ ## Asset Status
472
+
473
+ Assets progress through these statuses:
474
+ - `uploading` - Multipart upload is accepting more parts
475
+ - `pending` - Asset is being processed
476
+ - `approved` - Processing completed and delivery is allowed; an `alert` action can still leave `report.flagged` set to `true`
477
+ - `rejected` - A hide, purge, child-asset, or manual rejection decision was applied
478
+ - `error` - Processing failed
479
+
480
+ ## Moderation Reports
481
+
482
+ Completed reports always contain the six parent categories: `sexual`, `violence`, `self-harm`, `harassment`, `hate`, and `criminal-activity`. Category-specific detail is nested under `qualifiers`, for example `violence.qualifiers.graphic`. Reports have no `subcategories` field or dedicated minor-safety placeholder. Retired generic qualifier IDs are rejected.
483
+
484
+ Always narrow on a category's `status` before reading `score`. An `evaluated` or `partial` result includes `score` and `likelihood`; a `not-evaluated` result includes a reason and represents unknown coverage, not a safe zero score.
485
+
486
+ ```typescript
487
+ const violence = asset.report?.categories.violence;
488
+ if (violence?.status === 'evaluated' || violence?.status === 'partial') {
489
+ console.log(violence.score);
490
+ }
491
+ ```
492
+
493
+ See the [moderation report guide](https://calmlens.com/docs/guides/moderation-reports) for aggregation, provider coverage, OCR merging, and policy actions. General Sexual Content moderation does not estimate age or provide dedicated CSAM detection.
494
+
495
+ ## Asset Visibility
496
+
497
+ - `public` - Retained content can be requested directly from the CDN
498
+ - `private` - Retained content requires a signed URL or an authenticated owner session
499
+
500
+ ## Error Handling
501
+
502
+ The SDK throws errors for common issues:
503
+
504
+ ```typescript
505
+ try {
506
+ const asset = await client.assets.create({
507
+ name: "test.jpg",
508
+ // Missing required content, URL, or multipart part
509
+ });
510
+ } catch (error) {
511
+ console.error(error.message); // "Either file content, URL, or multipart part is required"
512
+ }
513
+ ```
514
+
515
+ Public API failures are normalized to `CalmLensError`. Use its stable `code` property for control flow and `details` for diagnostics.
516
+
517
+ ## Testing
518
+
519
+ `@calmlens/sdk/testing` exports a matching object-parameter `MockCalmLensClient`, in-memory storage implementations, and mock API key helpers for local unit testing without network requests.
520
+
521
+ The main `@calmlens/sdk` entry point does not load or export testing helpers to keep production bundles minimal. Keep `/testing` imports in test files.
522
+
523
+ ```typescript
524
+ import { MockCalmLensClient, createMockApiKey } from "@calmlens/sdk/testing";
525
+
526
+ const client = new MockCalmLensClient({
527
+ apiKey: createMockApiKey(),
528
+ projectId: crypto.randomUUID(),
529
+ });
530
+ ```
531
+
532
+ `MockCalmLensClient` accepts the same options as `CalmLensClient` (`apiKey`, `projectId`, `baseUrl`, `cdnBaseUrl`, `timeoutMs`, ...) and validates them the same way, plus `mockOptions`. `cdn.getAssetUrl` uses `cdnBaseUrl` (default `mock://calmlens/v1`).
533
+
534
+ Every call is recorded in `client.calls` as `{ operation, options }`, where `operation` is the method path (`"text.moderate"`, `"assets.retrieve"`, `"events.list"`, ...) and `options` is the object you passed. `client.callsTo(operation)` filters them. To test failure handling, `client.failNextRequest()` makes the next call throw the same `CalmLensError` the real client throws when the network fails (`sdk/transport-error`); pass `"timeout"` for `sdk/timeout`, or any `Error` to throw it as is, and `{ operation }` to fail only that method. Each queued failure is used once.
535
+
536
+ ```typescript
537
+ const client = new MockCalmLensClient({ apiKey: createMockApiKey(), projectId, timeoutMs: 5_000 });
538
+
539
+ client.failNextRequest("timeout", { operation: "text.moderate" });
540
+ await expect(postComment(client, "hello")).rejects.toMatchObject({ code: "sdk/timeout" });
541
+
542
+ await postComment(client, "hello");
543
+ expect(client.callsTo("text.moderate")[0]?.options).toMatchObject({ text: "hello" });
544
+ ```
545
+
546
+ For `mockOptions`, deterministic timestamps and IDs, storage inspection, and testing recipes, see the [JavaScript SDK Testing Guide](https://calmlens.com/docs/sdk/javascript/testing).
547
+
548
+ ## Webhooks
549
+
550
+ A `webhook` connector POSTs each event to your URL as JSON:
551
+
552
+ ```json
553
+ {
554
+ "id": "…",
555
+ "type": "asset.flagged",
556
+ "createdAt": 1760000000000,
557
+ "projectId": "…",
558
+ "data": {
559
+ "severity": "high",
560
+ "title": "…",
561
+ "description": null,
562
+ "assetId": "…",
563
+ "asset": null,
564
+ "facts": [{ "key": "asset-name", "value": "photo.jpg" }]
565
+ }
566
+ }
567
+ ```
568
+
569
+ Each delivery has these headers (the names are exported as constants):
570
+
571
+ | Header | Constant | Value |
572
+ |---|---|---|
573
+ | `x-calmlens-delivery` | `WEBHOOK_DELIVERY_HEADER` | Delivery ID (the same across retries) |
574
+ | `x-calmlens-timestamp` | `WEBHOOK_TIMESTAMP_HEADER` | Unix time in seconds when the delivery was signed |
575
+ | `x-calmlens-signature` | `WEBHOOK_SIGNATURE_HEADER` | `v1=<lowercase hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>` |
576
+
577
+ ```typescript
578
+ import {
579
+ verifyWebhookSignature,
580
+ WEBHOOK_SIGNATURE_HEADER,
581
+ WEBHOOK_TIMESTAMP_HEADER,
582
+ } from "@calmlens/sdk";
583
+
584
+ const payload = await request.text();
585
+ const valid = await verifyWebhookSignature({
586
+ payload,
587
+ signature: request.headers.get(WEBHOOK_SIGNATURE_HEADER),
588
+ timestamp: request.headers.get(WEBHOOK_TIMESTAMP_HEADER),
589
+ secret: process.env.CALMLENS_WEBHOOK_SECRET!,
590
+ });
591
+ ```
592
+
593
+ Verify the exact raw request body with `verifyWebhookSignature` before parsing it; re-serializing parsed JSON can change the signed bytes. Deliveries more than 5 minutes old (or ahead of your clock) are rejected to prevent replays. The body type is exported as `WebhookEventPayload`; narrow it on `type` to get that event type's typed `data.facts` (`EventType` and `EventFacts<Type>` are exported too, and [the webhooks guide](https://calmlens.com/docs/guides/webhooks#event-types) lists every type).
594
+
595
+ ## Command-line tool
596
+
597
+ Install the standalone [CalmLens CLI](https://calmlens.com/docs/guides/local-development) through Homebrew, a platform download or npm. Webhook connectors must use public HTTPS URLs, so during development use it to forward events to a local handler instead. Each event is sent with the same body, headers and signature as a real delivery:
598
+
599
+ ```bash
600
+ npx calmlens listen --forward-to http://localhost:3000/api/webhooks/calmlens --env-file .env.local
601
+ ```
602
+
603
+ The CLI reads `CALMLENS_API_KEY`, `CALMLENS_PROJECT_ID` and `CALMLENS_WEBHOOK_SECRET` from the environment or `--env-file`; without a secret it prints a generated one for your app. `npx calmlens trigger [harassment|hate|criminal-activity|clean]` moderates a harmless test input so events appear (one upload each). Run `npx calmlens --help` for all options. To install the command globally, use `npm install -g calmlens`. The CLI is released separately from the SDK.
604
+
605
+ The same feed is available in code with [`events.list`](#events-feed).
606
+
607
+ ## Events feed
608
+
609
+ `client.events.list({ after?, limit? })` reads the project's events, oldest first, without a public webhook URL. It resolves to an `EventsPage`:
610
+
611
+ ```typescript
612
+ {
613
+ events: TypedEvent[]; // { id, projectId, type, severity, title, description?, assetId?, asset?, facts, createdAt, updatedAt }
614
+ cursor: string; // pass back as `after` to continue after the last event returned
615
+ hasMore: boolean; // true when more events are already waiting after `cursor`
616
+ }
617
+ ```
618
+
619
+ - Without `after` (or with an empty string), it returns **no events** and a `cursor` for the current time, so you only see events created after you start reading. It does not return history.
620
+ - With `after`, it returns up to `limit` events (default 50) created after that cursor. When there are none, `cursor` is the `after` you passed.
621
+ - Keep passing the latest `cursor` as `after` until `hasMore` is `false`, then wait before polling again. Store the cursor if you want to resume after a restart.
622
+ - An unknown or malformed cursor fails with the `CalmLensError` code `events/invalid-cursor`.
623
+
624
+ ```typescript
625
+ let { cursor } = await client.events.list(); // no events, cursor = "now"
626
+
627
+ async function drainEvents() {
628
+ let hasMore = true;
629
+ while (hasMore) {
630
+ const page = await client.events.list({ after: cursor, limit: 100 });
631
+ for (const event of page.events) {
632
+ // narrow on event.type to read its typed facts
633
+ console.log(event.type, event.facts);
634
+ }
635
+ cursor = page.cursor;
636
+ hasMore = page.hasMore;
637
+ }
638
+ }
639
+
640
+ setInterval(drainEvents, 5_000);
641
+ ```
642
+
643
+ Events carry `facts: { key, value, url? }[]`; webhook deliveries carry the same events reshaped as `{ id, type, createdAt, projectId, data }`.
644
+
645
+ ## Request budgets and errors
646
+
647
+ Set `timeoutMs` (default 120000) or `signal` on the client. Use `client.withRequestOptions({ signal, timeoutMs: 30000 })` to scope them to an operation without changing the original client. Multipart state is shared between scoped clients. Deadlines include response-body reads; authenticated default REST, upload and CDN requests reject redirects. Custom non-Fetch backends own their redirect/cancellation policies.
648
+
649
+ `CalmLensError` exposes `code`, optional HTTP `status`, `details` and native `cause`. Every failure to complete a request (DNS, TLS, refused or reset connections, truncated bodies, decompression errors) is `sdk/transport-error`, with the underlying error as `cause`; deadlines are `sdk/timeout`, cancellation `sdk/cancelled`, successful responses whose body is not the expected JSON `sdk/invalid-response`, and HTTP failures without an API error body `sdk/http-error`. `cdn.serve` returns the `Response` once headers arrive, so errors while you read its body come from your `fetch`. API error codes/messages are preserved. Core model types (`Asset`, `Folder`, `Lens`, `AssetPage`, `EventsPage`, `ClassificationReport`) and request types are exported from the package root.
650
+
651
+ `text.moderate` is a convenience wrapper around asset moderation: it supplies `text/plain` content and defaults the asset name to `text`. `assets.create` queues processing; `assets.moderate` waits for classification. The old asset analyze method is removed.
652
+
653
+ ## Releasing
654
+
655
+ Stable releases are published automatically from `main` when packed SDK contents differ from npm `latest` and an SDK Changeset is present. Contributors must run `bun run changeset` to choose the release type and write release notes. CI updates and commits this package's `CHANGELOG.md` automatically. See the repository's [.changeset release guide](../../.changeset/README.md).