# Java SDK reference

This page details how the [Java SDK](/docs/sdk/java) behaves, method by method. Start with the SDK page for installation, the first request and the method list.

## Client

`CalmLensServerClient.builder(apiKey, projectId).build()` creates a synchronous, thread-safe client. Create one client per API key, share it across threads and close it on shutdown. The JAR declares the module name `com.calmlens` (`requires com.calmlens;`).

`close()` makes later calls fail with `sdk/client-closed` and, on Java 21 and later, closes the built-in `HttpClient`; on Java 17 it is released by garbage collection. An `httpClient` you pass to the builder is never closed by the SDK.

Requests send `User-Agent: calmlens-java/<version> (java/<runtime version>)` and, for API requests, `Accept: application/json`. `CalmLensServerClient.VERSION` is read from the Maven manifest. `toString()` never includes the API key, and `SignedUrl.toString()` hides the URL's signature.

## Request options

Request options live in `com.calmlens.Models`: required fields have constructors and fluent setters configure the rest. Unset options are not sent. In update options, `Patch.value(value)` sets a field, `Patch.clear()` clears it, and leaving it unset keeps it:

```java
client.assets.update(new UpdateAsset(asset.id()).description(Patch.clear()));
```

`Content.mimeType(...)` and `LensDefinition.lensType(...)` set the API's `type` field, because `type` would be ambiguous among the other setters.

## Assets

`assets.create` queues processing and returns the asset while its `classification().status()` is `processing`. `assets.classify` and `text.classify` wait for classification. `text.classify` sends `text/plain` content and names the asset `text` unless you set a name. `assets.create(name, bytes, mimeType)` is a shortcut for a `SubmitAsset` with `Content.bytes`.

| Method                 | Behaviour                                                                                                                                                                                                                                              |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `assets.deleteContent` | Removes the stored bytes. The record and report stay, with `content().status()` `removed` and `content().removalReason()` `requested`.                                                                                                                 |
| `assets.retry`         | Processes an asset whose `classification().status()` is `failed` again. `classification().failureReason()` says why it failed (see [Failure reasons](/docs/guides/asset-lifecycle#failure-reasons)) and is `null` otherwise.                           |
| `assets.explain`       | Completes the policy evidence behind a decision: `policy().evidence()` becomes `true` and every action stays as decided. With `rerun(true)` it moderates the asset again under the current policy.                                                     |
| `assets.decide`        | Records a reviewer's decision with its note: `new DecideAsset(id, AssetModerationDecision.BLOCKED, note)`. An asset still pending moderation can only be blocked or purged, and purging needs `confirmPurge(true)` and the `assets:delete` permission. |
| `assets.delete`        | Removes the record, its report and its content.                                                                                                                                                                                                        |
| `assets.batch`         | Deletes up to 500 assets, or removes their content, in one request (see below).                                                                                                                                                                        |

### Batch operations

```java
BatchAssetsResponse queued = client.assets.batch(new BatchAssets().assetIds(ids).operation(AssetBatchOperation.DELETE));
```

`AssetBatchOperation.REMOVE_CONTENT` removes the assets' content instead. Every asset must exist and the key needs `assets:delete` for its folder, or nothing is queued ([`404 asset/not-found`](/docs/api/assets#upload-asset-part-error-asset-not-found), [`403 auth/permission-denied`](/docs/api/assets#create-asset-error-auth-permission-denied)). The response's `queued()` is true once the work is queued; it runs in the background and is not retried. See [`POST /projects/:projectId/assets/batch`](/docs/api/assets#run-asset-batch-operation).

### Optional analyses

Optional analyses are turned on per request with `ProcessingOptions.features`, and every feature is off unless you enable it. [OCR](/docs/guides/ocr) stores text in the image's `content().extractedText()` and adds a text moderation pass to its combined report.

```java
SubmitAsset photo = new SubmitAsset("photo.jpg", Content.file(Path.of("photo.jpg"), "image/jpeg"))
    .processingOptions(new ProcessingOptions().features(new ProcessingFeatures()
        .ocr(new FeatureOptions(true))));
Asset asset = client.assets.classify(photo);
System.out.println(asset.content().extractedText());
```

### Conversation context

For a reply in a thread, set `authorId` (your label for who wrote the content) and `context` (earlier posts in the conversation, oldest first) on `SubmitAsset` or `ClassifyText`. CalmLens moderates only the content itself and uses the earlier posts to read who it addresses, what it refers to and whether it quotes or reports something. See [Threaded moderation](/docs/guides/threaded-moderation).

```java
Asset reply = client.text.classify(new ClassifyText("Same to you")
    .authorId("user-9")
    .context(new ModerationContext(List.of(
        new ModerationContextItem("Nobody asked you", "user-3"),
        new ModerationContextItem("Please keep it civil")))));
```

The client sends only the posts CalmLens uses: the most recent `MODERATION_CONTEXT_USED_ITEMS` (5) posts within `MODERATION_CONTEXT_USED_CHARACTERS` (2,000) characters, counted as Unicode code points. The newest post is always sent, and `Models.selectModerationContextItems(items)` returns the posts that would be sent. A null item list, post or text is refused with `sdk/invalid-argument`. The asset returns `authorId()` and the `context()` it used; `context()` is `null` once the content is removed.

## Hash lists

[Hash lists](/docs/guides/hash-lists) hold PDQ perceptual hashes that image uploads are compared with. A `HashListAction.FLAG` list reports matches in `classification().moderation().raw().hashMatches()`; a `HashListAction.BLOCK` list also blocks close matches, and the decision's `policy().decision().source()` is `hash-list`.

```java
HashList list = client.hashLists.create(new CreateHashList("Removed images", HashListAction.BLOCK));
HashListEntry entry = client.hashLists.addEntry(new AddHashListEntry(list.id()).assetId(asset.id()).label("Report 1042"));
HashListEntryPage page = client.hashLists.listEntries(new ListHashListEntries(list.id()).pageSize(50));
client.hashLists.update(new UpdateHashList(list.id()).action(Patch.value(HashListAction.FLAG)).description(Patch.clear()));
client.hashLists.removeEntry(new HashListEntryId(entry.id()));
```

`hashLists.addEntry` takes a 64-character hex `hash` (with an optional `quality`) or an image `assetId`, whose `content().image().perceptualHash()` is added; an asset without one fails with [`409 hash-list/asset-not-hashed`](/docs/api/hash-lists#create-hash-list-entry-error-hash-list-asset-not-hashed). Adding a hash the list already holds returns the existing entry. `hashLists.delete` removes the list and every entry in it.

## Folders

`folders.list` returns a page of `items`, the `ancestors` needed to show their paths, `nextCursor` and `hasMore`. Set `parentId` to open a branch (omit it for the project root), and pass `nextCursor()` as `cursor` to load another page. `pageSize` is 1 to 100 (default 50), and `search` matches names among the parent's children.

## Pagination

`hashLists.list` and `folders.list` return cursor pages like `assets.list`. `assets.iterate` and `assets.iterateChildren` return an `AutoPager`, an `Iterable` that requests the next page only when needed. See [Pagination and filtering](/docs/guides/pagination-and-filtering) for filters.

```java
for (Asset asset : client.assets.iterate(new ListAssets().filter("classification.moderation.status==blocked").pageSize(100))) {
  handle(asset);
}
client.assets.iterateChildren(new ListChildren(photoId)).stream().filter(child -> "plaintext-document".equals(child.type()));
```

The events feed (`events.list`, `events.pages`, `pageSize` 1 to 100, default 50) returns every event type, oldest first; filter by `type()` and `assetId()`. Prefer webhooks in production.

## Responses

Responses are immutable records whose accessors keep the API's camelCase names. An asset's state is grouped in three records:

| Record                                     | Holds                                                                                                                                                                                                                                              |
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `upload()` (`AssetUpload`)                 | Receipt of the source content.                                                                                                                                                                                                                     |
| `classification()` (`AssetClassification`) | Progress, completion time, `failureReason()` when processing failed, detections and labels, with safety findings and decisions in `classification().moderation()`.                                                                                 |
| `content()` (`AssetContent`)               | Whether CalmLens holds the content, and its `format()`, `sizeInBytes()`, `checksums()`, `image()`, `extractedText()`, `fileMetadata()` (metadata embedded in an uploaded image, such as its capture time, camera and software) and `safetyHold()`. |

Event and webhook snapshots (`EventAsset`) carry the processing, moderation and content statuses when the event was created. `classification().moderation().categories()` and `score()` are absent until findings are available.

Optional values are `null` when the API omits them, lists inside responses are immutable, and timestamps are `java.time.Instant`. Nested objects are typed records (`AssetModeration`, `Checksums`, `AssetImage`, `SafetyHold`, `ExtractedText`, `FileMetadata`, `ImageTransformOptions` and so on). Only metadata values and event fact values, where the API accepts any JSON, are untyped: they use `String`, `Long`, `Double`, `Boolean`, `List` and `Map`, so `9007199254740993` stays exact and `5` is `5L`, never `5.0`.

### Forward compatibility

Responses from newer API versions keep working: unknown fields are ignored, and values from a fixed set are read as strings, so an unknown status or visibility keeps its raw value (`status().value()`, with `isKnown()` `false`) instead of failing. Known values are `AssetClassificationStatus`, `AssetModerationStatus`, `AssetModerationDecision` (a reviewer's decision in `manualReviews()`) and `AssetContentStatus` constants you can compare with `==`.

Show content publicly only when its moderation status is `ALLOWED` or `LIMITED`. `LIMITED` content is delivered like allowed content, but keep it out of recommendations, trending and notifications, rank it lower or collapse it; see [Limited assets](/docs/guides/delivering-assets#limited-assets). Show `AGE_RESTRICTED` content only behind your own age gate, through signed URLs. Don't show `BLOCKED` or `PURGED` content, or any status your SDK version doesn't know.

## Uploads

`Content.text(text)`, `Content.bytes(bytes, mime)`, `Content.file(path, mime)` and `Content.stream(inputStream, sizeInBytes, mime)` create upload content. Files and streams are sent as they are read, with a `Content-Length`, so large uploads are never held in memory; bytes are sent without copying or base64 encoding. `Content.stream(inputStream, mime)` sends a stream of unknown size, chunked.

### Multipart uploads

Send the first part with `assets.create(new SubmitAsset().name(...).part(content.partNumber(1).isFinal(false)))`, optionally with `.totalSizeInBytes(size)` so CalmLens checks the total up front, then each later part with `assets.uploadPart(new UploadPart(assetId, content.partNumber(n).isFinal(last)))`. See [Multipart uploads](/docs/guides/multipart-uploads).

Non-final parts must be at least 5 MiB, and image uploads in parts must total 10 to 20 MiB. The client refuses a part it can measure before sending it:

| Part                                                  | Refused with                                                                                                   |
| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| A non-final part under 5 MiB                          | [`400 upload/invalid-payload`](/docs/api/assets#create-asset-error-upload-invalid-payload)                     |
| A first image part over 20 MiB                        | [`400 upload/multipart-image-too-large`](/docs/api/assets#create-asset-error-upload-multipart-image-too-large) |
| A final image part that leaves the total under 10 MiB | [`400 upload/multipart-image-too-small`](/docs/api/assets#create-asset-error-upload-multipart-image-too-small) |

CalmLens checks the totals across parts and answers with the same codes, or [`400 upload/size-mismatch`](/docs/api/assets#upload-asset-part-error-upload-size-mismatch). CalmLens saves the queue choice when the upload starts, so parts can be sent from any client or process; `UploadPart.queue(Boolean)` overrides it for the final part.

### Idempotency keys

Every upload carries an `Idempotency-Key`. The client generates one per call and reuses it for that call's retries, so a retried upload never creates a second asset. To retry safely across processes, pass your own key: 1 to 255 printable ASCII characters, without a space at either end (CalmLens refuses others with [`400 idempotency/invalid-key`](/docs/api/assets#create-asset-error-idempotency-invalid-key)).

```java
client.assets.create(options, new RequestOptions().idempotencyKey(listing.id() + ":photo-1"));
```

### Upload tokens

`client.uploadTokens.sign` signs a single-use token locally with your API key, without a request, for a browser upload through an [upload preset](/docs/guides/upload-presets) that requires tokens:

```java
SignedUploadToken signed = client.uploadTokens.sign(
    new SignUploadToken("preset_4Lk8Qw2Xm9Rt5Vb3Nc7P")
        .externalId("post-42")        // optional: the uploaded asset gets this ID
        .expiresInSeconds(600));      // optional: 1 to 3600, 600 by default
// signed.token(), signed.expiresAt() (milliseconds since the Unix epoch)
```

## Webhooks

`CalmLensServerClient.parseWebhookEvent(body, signature, timestamp, secret)` verifies the exact raw body bytes, then parses them. It takes only `byte[]` or `String` bodies: never pass a body your framework has already parsed and re-serialized. See [Webhooks](/docs/guides/webhooks).

In Spring, the `byte[]` parameter receives the raw body:

```java
@PostMapping("/webhooks/calmlens")
ResponseEntity<Void> calmlens(@RequestBody byte[] body,
    @RequestHeader(CalmLensServerClient.WEBHOOK_SIGNATURE_HEADER) String signature,
    @RequestHeader(CalmLensServerClient.WEBHOOK_TIMESTAMP_HEADER) String timestamp) {
  WebhookEventPayload event;
  try {
    event = CalmLensServerClient.parseWebhookEvent(body, signature, timestamp, webhookSecret);
  } catch (CalmLensException invalid) {
    return ResponseEntity.status(invalid.getCode().equals(ErrorCodes.SDK_INVALID_WEBHOOK_SIGNATURE) ? 401 : 400).build();
  }
  queue.add(event); // deduplicate by event.id(), then retrieve the asset for its current state
  return ResponseEntity.noContent().build();
}
```

In a servlet (Jakarta or javax), read and cap the body yourself:

```java
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
  byte[] body = request.getInputStream().readNBytes(1024 * 1024 + 1);
  if (body.length > 1024 * 1024) { response.setStatus(413); return; }
  try {
    WebhookEventPayload event = CalmLensServerClient.parseWebhookEvent(body,
        request.getHeader(CalmLensServerClient.WEBHOOK_SIGNATURE_HEADER),
        request.getHeader(CalmLensServerClient.WEBHOOK_TIMESTAMP_HEADER), webhookSecret);
    queue.add(event);
    response.setStatus(204);
  } catch (CalmLensException invalid) {
    response.setStatus(invalid.getCode().equals(ErrorCodes.SDK_INVALID_WEBHOOK_SIGNATURE) ? 401 : 400);
  }
}
```

A verified delivery must be a JSON object with string `id`, `type` and `projectId` and a numeric `createdAt`, and its other known fields must have the API's types; otherwise parsing fails with `sdk/invalid-webhook-payload`. `json()` holds the whole body as received, including event types your SDK version doesn't model.

| Helper                                                                                                                      | Purpose                                                                                                                                                                                            |
| --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `parseWebhookRequest(headers, body, secret)`, `verifyWebhookRequest(headers, body, secret)`                                 | Read the signature and timestamp from the request's headers. Header names match in any case, and a header repeated in the request (a list value, as in `HttpHeaders.map()`) is joined with commas. |
| `verifyWebhookSignature(body, signature, timestamp, secret)`                                                                | Returns a boolean instead of throwing. Overloads take `nowSeconds` and `toleranceSeconds`.                                                                                                         |
| `isTestWebhookEvent(event)`                                                                                                 | True for test sends from the portal or API, which carry the `{ key: "test", value: true }` fact.                                                                                                   |
| `createWebhookSignatureHeader(secret, timestamp, body)`, `createWebhookHeaders(secret, body, deliveryId, timestampSeconds)` | Sign test deliveries; see [Webhook handlers](/docs/sdk/java/testing#webhook-handlers).                                                                                                             |
| `toWebhookEventPayload(event)`                                                                                              | Turns an event from `events.list` into its delivery body.                                                                                                                                          |

The header names are `WEBHOOK_SIGNATURE_HEADER`, `WEBHOOK_TIMESTAMP_HEADER` and `WEBHOOK_DELIVERY_HEADER`, and the tolerance is `WEBHOOK_SIGNATURE_TOLERANCE_SECONDS` (300).

## CDN

`cdn.getAssetUrl(new CdnRequest(id).lens("thumb"))` builds a CDN URL. `CdnRequest.resource` is `rendered` (the default), `original` or `placeholder`; only `rendered` takes a lens or transform.

`cdn.serve` streams content with the API key and returns a `CdnResponse` for any status but a redirect, which fails with `sdk/http-error` and is never followed. Close the response when done. Transport failures and HTTP 429, 502, 503 and 504 are retried like API reads; once retries run out, the last response is returned. `readAllBytes(maxBytes)` buffers a body up to a limit. The client's `timeout` bounds the wait for the response headers, and a stream read that then receives nothing for that long fails with `HttpTimeoutException`; pass `new RequestOptions().timeout(...)` to `cdn.serve` to change it per call. Use `assets.createSignedUrl` for private content in browsers.

### Transforms

To transform an asset without a saved [lens](/docs/guides/lenses), pass `transform` on `CdnRequest` or `SignedUrlOptions`, as a selector string or as structured steps, which the SDK serializes with sorted, encoded options:

```java
client.cdn.getAssetUrl(new CdnRequest(id).transform("format=png,width=1024"));
client.cdn.getAssetUrl(new CdnRequest(id).transform(
    Transform.image(new ImageTransformOptions().format("png").width(1024))));
client.cdn.getAssetUrl(new CdnRequest(id).transform(List.of(
    Transform.image(new ImageTransformOptions().border(new ImageBorder("#fff", 2.0))
        .gravity(Gravity.position("face")).trim(Trim.border())),
    Transform.image(new ImageTransformOptions().format("webp")))));
```

A selector has one path segment per step, such as `fit=cover,width=640/format=webp`: options are sorted `key=value` pairs, nested options are dotted (`border.color=fff`) and hex colours drop the `#`. A lens runs before any additional transforms, and the two are joined with `/` (`/rendered/@thumbnail/width=320`). `quality` takes `Quality.of(80)` or `Quality.level("high")`.

`redact(new Redaction().text(true))` blurs the text regions found when the image was processed before any other option applies, and `personalInformation(true)` blurs only those holding personal information. Text regions need OCR on the upload; see [Text regions and redaction](/docs/guides/ocr#text-regions-and-redaction).

### Viewer claims

`viewerClaims(new ViewerClaims())` on `CdnRequest` or `SignedUrlOptions` sends viewer claims with a CDN request: `region(...)` and `ageConfirmed(...)`. The SDK appends them as a JSON `viewerClaims` query parameter. A public asset whose moderation status is `age-restricted` is served only with `ageConfirmed(true)`; otherwise delivery is refused with [`403 viewer/age-confirmation-required`](/docs/api/cdn#error-viewer-age-confirmation-required), even with a signed URL or API key. Set it only where your app has already decided the viewer may see age-restricted content, and leave it unset in processing jobs. The region defaults to the country of the connection reaching CalmLens, so a proxy should forward the incoming viewer's country. Retrieving an asset returns its stored moderation results, without a delivery result for any particular viewer. See [Age-restricted assets](/docs/guides/delivering-assets#age-restricted-assets) and [Viewer region](/docs/guides/jurisdiction-rules#viewer-region).

## Errors

Every failure is an unchecked `CalmLensException` with a stable `getCode()`; compare it with the constants in `ErrorCodes`, which include every public API error code.

| Type                      | When                                                                                                                                                                                                                                 | Extra information                                                                                                                    |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `ApiException`            | The API answered with an error, or with a body the SDK could not read (`sdk/invalid-response`, `sdk/response-too-large`, `sdk/http-error`)                                                                                           | `getStatus()`, `getRequestId()` (`X-Request-Id`, or the error body's `requestId`), `getHeaders()`, `getRetryAfter()`, `getDetails()` |
| `TransportException`      | The request could not be sent or its response could not be read (`sdk/transport-error`)                                                                                                                                              | `getCause()` is the original exception                                                                                               |
| `RequestTimeoutException` | A request timed out (`sdk/timeout`); it is a `TransportException`                                                                                                                                                                    |                                                                                                                                      |
| `CalmLensException`       | Invalid arguments (`sdk/invalid-argument`), configuration (`sdk/invalid-config`), upload payloads (`upload/*`), webhooks, interruption (`sdk/cancelled`, with the interrupt flag kept) and use after `close()` (`sdk/client-closed`) |                                                                                                                                      |

`isNotFound()` and `isRetryable()` help branch on common cases. `isRetryable()` follows from the code and status alone, whatever the exception's class. Validation failures are reported before anything is sent, and header values containing line breaks or control characters are rejected as `sdk/invalid-argument`. Log `getRequestId()` with failures and include it when contacting support. See [Error handling](/docs/guides/error-handling).

## Retries and timeouts

Reads (`GET`, `HEAD`) and uploads are retried up to `maxRetries` times (2 by default; `0` disables retries) after network failures, timeouts, HTTP 429, 502, 503 and 504, and [`409 idempotency/request-in-progress`](/docs/api/assets#create-asset-error-idempotency-request-in-progress). Uploads reuse their `Idempotency-Key` on every retry. Other writes are never retried automatically, and uploads from `Content.stream` can be read only once, so they are not retried either. Repeat a write yourself only with the same idempotency key.

Before retry `n` (from 0), the client waits `d/2` plus a random part of `d/2`, where `d` is 500 ms doubled `n` times, at most 8 seconds. A `Retry-After` in seconds or as an HTTP date replaces that wait (`0` retries at once), and one over 60 seconds is returned as the error instead.

```java
CalmLensServerClient.builder(apiKey, projectId)
    .connectTimeout(Duration.ofSeconds(10))   // opening a connection
    .timeout(Duration.ofSeconds(120))         // each API request attempt, response body included; text uploads too
    .uploadTimeout(Duration.ofMinutes(30))    // each binary upload attempt; none by default
    .maxRetries(2)
    .maxResponseBytes(32L * 1024 * 1024)      // larger API responses fail with sdk/response-too-large
    .build();
```

Override `timeout`, `uploadTimeout` and `maxRetries` for one call with `RequestOptions`, or for every call of a derived client that shares this one's connection and lens cache with `client.withRequestOptions(new RequestOptions().timeout(Duration.ofMinutes(5)))`. Pass `httpClient(...)` to use your own `java.net.http.HttpClient` (proxy, TLS, executor), or `transport(...)` to send requests through another HTTP stack. Cancel a call by interrupting its thread: it fails with `sdk/cancelled` and is not retried.

## Lenses

`ensureLens` and `ensureLenses` create or update versioned [lens](/docs/guides/lenses#versioned-custom-lenses) definitions. `ensureLenses` sends only definitions this client hasn't ensured yet, in requests of at most 100 (`ENSURE_LENSES_MAX_PER_REQUEST`), and concurrent calls for the same new definitions share one request.

## Compared with the JavaScript SDK

The Java SDK follows the [JavaScript SDK](/docs/sdk/javascript)'s names (`assets.classify`, `cdn.serve`, `verifyWebhookSignature`, `createWebhookSignatureHeader(secret, timestamp, body)`, `WEBHOOK_SIGNATURE_TOLERANCE_SECONDS`, `event.type()`), with these differences:

- Webhook helpers are static methods on `CalmLensServerClient`, and `parseWebhookEvent` takes the body, signature, timestamp and secret as arguments instead of an options object.
- `cdn.serve` returns a closeable `CdnResponse` with a streamed body instead of a fetch `Response`.
- Request options are builders, with `Content.mimeType(...)` and `LensDefinition.lensType(...)` for the API's `type` field.
- `assets.iterate`, `assets.iterateChildren` and `events.pages` return `Iterable` pagers instead of async generators.
- Per-call idempotency keys, deadlines and retries are passed as `RequestOptions`, and `withRequestOptions` takes the same class. Calls are cancelled by interrupting their thread.
