# Classify assets

Submit files, inline content or remote URLs for moderation, using multipart uploads for larger files. [Authenticate](/docs/guides/authentication) with a project API key on your server; see the [Assets API reference](/docs/api/assets) for fields and errors.

## Upload with an SDK

For files, pass a file, buffer or stream to your SDK. It selects the upload mode and handles authentication.

### ts-sdk

```typescript
import { readFile } from "node:fs/promises";
const asset = await client.assets.classify({
  name: "photo.jpg",
  content: { buffer: await readFile("./photo.jpg"), type: "image/jpeg" },
});
// Audio and video can still be pending; publish only approved assets.
console.log(asset.classification.moderation.decision);
```

### js-sdk

```javascript
import { readFile } from "node:fs/promises";
const asset = await client.assets.classify({
  name: "photo.jpg",
  content: { buffer: await readFile("./photo.jpg"), type: "image/jpeg" },
});
// Audio and video can still be pending; publish only approved assets.
console.log(asset.classification.moderation.decision);
```

### python

```python
with open("cat.jpg", "rb") as content:
    asset = client.assets.classify({
        "name": "cat.jpg",
        "visibility": "private",
        "processingOptions": {"keepContentAfterProcessing": True},
        "content": {"data": content, "type": "image/jpeg"},
    })
print(asset["id"])
```

### go

```go
package main
import (
    "context"
    "fmt"
    "log"
    "os"
    calmlens "github.com/CalmLens/sdks/go"
)
func example(client *calmlens.ServerClient) {
    ctx := context.Background()
    content, err := os.Open("cat.jpg")
    if err != nil { log.Fatal(err) }
    defer content.Close()
    asset, err := client.Assets.Classify(ctx, calmlens.SubmitAssetOptions{Name: "cat.jpg", Visibility: "private", ProcessingOptions: &calmlens.ProcessingOptions{KeepContentAfterProcessing: calmlens.Ptr(true)}, Content: &calmlens.Content{Data: content, Type: "image/jpeg"}})
    if err != nil { log.Fatal(err) }
    fmt.Println(asset.ID)
}
```

### rust

```rust
use calmlens::{models::*, ServerClient};
async fn example(client: &ServerClient) -> Result<(), Box<dyn std::error::Error>> {
    let bytes = std::fs::read("cat.jpg")?;
    let asset = client.assets().classify(SubmitAsset::new("cat.jpg", Content::bytes(bytes, "image/jpeg"))).await?;
    println!("{}", asset.id);
    Ok(())
}
```

### ruby

```ruby
asset = client.assets.classify(name: 'cat.jpg', content: {data: File.binread('cat.jpg'), type: 'image/jpeg'})
puts asset['id']
```

### java

```java
import com.calmlens.CalmLensServerClient;
import com.calmlens.Models.*;
public class Example {
  public static void example(CalmLensServerClient client) throws Exception {
    byte[] bytes = java.nio.file.Files.readAllBytes(java.nio.file.Path.of("cat.jpg"));
    Asset asset = client.assets.classify(new SubmitAsset("cat.jpg", Content.bytes(bytes, "image/jpeg")).visibility(Visibility.PRIVATE).processingOptions(new ProcessingOptions().keepContentAfterProcessing(true)));
    System.out.println(asset.id());
  }
}
```

### curl

```bash
curl -X POST "https://api.calmlens.com/v1/projects/$CALMLENS_PROJECT_ID/assets?name=cat.jpg&contentType=image%2Fjpeg&queue=false&visibility=private&processingOptions=%7B%22keepContentAfterProcessing%22%3Atrue%7D" \
  -H "X-Api-Key: $CALMLENS_API_KEY" \
  -H "X-CalmLens-Upload: content" \
  -H "Content-Type: image/jpeg" \
  --data-binary @cat.jpg
```

### ts-sdk

```typescript
import { readFile } from "node:fs/promises";
const asset = await client.assets.create({
  name: "photo.jpg",
  content: { buffer: await readFile("./photo.jpg"), type: "image/jpeg" },
});
// Save this ID; the webhook supplies the moderation decision.
console.log(asset.id);
```

### js-sdk

```javascript
import { readFile } from "node:fs/promises";
const asset = await client.assets.create({
  name: "photo.jpg",
  content: { buffer: await readFile("./photo.jpg"), type: "image/jpeg" },
});
// Save this ID; the webhook supplies the moderation decision.
console.log(asset.id);
```

### python

```python
with open("cat.jpg", "rb") as content:
    asset = client.assets.create({
        "name": "cat.jpg",
        "visibility": "private",
        "processingOptions": {"keepContentAfterProcessing": True},
        "content": {"data": content, "type": "image/jpeg"},
    })
print(asset["id"])
```

### go

```go
package main
import (
    "context"
    "fmt"
    "log"
    "os"
    calmlens "github.com/CalmLens/sdks/go"
)
func example(client *calmlens.ServerClient) {
    ctx := context.Background()
    content, err := os.Open("cat.jpg")
    if err != nil { log.Fatal(err) }
    defer content.Close()
    asset, err := client.Assets.Create(ctx, calmlens.SubmitAssetOptions{Name: "cat.jpg", Visibility: "private", ProcessingOptions: &calmlens.ProcessingOptions{KeepContentAfterProcessing: calmlens.Ptr(true)}, Content: &calmlens.Content{Data: content, Type: "image/jpeg"}})
    if err != nil { log.Fatal(err) }
    fmt.Println(asset.ID)
}
```

### rust

```rust
use calmlens::{models::*, ServerClient};
async fn example(client: &ServerClient) -> Result<(), Box<dyn std::error::Error>> {
    let bytes = std::fs::read("cat.jpg")?;
    let asset = client.assets().create(SubmitAsset::new("cat.jpg", Content::bytes(bytes, "image/jpeg"))).await?;
    println!("{}", asset.id);
    Ok(())
}
```

### ruby

```ruby
asset = client.assets.create(name: 'cat.jpg', content: {data: File.binread('cat.jpg'), type: 'image/jpeg'})
puts asset['id']
```

### java

```java
import com.calmlens.CalmLensServerClient;
import com.calmlens.Models.*;
public class Example {
  public static void example(CalmLensServerClient client) throws Exception {
    byte[] bytes = java.nio.file.Files.readAllBytes(java.nio.file.Path.of("cat.jpg"));
    Asset asset = client.assets.create(new SubmitAsset("cat.jpg", Content.bytes(bytes, "image/jpeg")).visibility(Visibility.PRIVATE).processingOptions(new ProcessingOptions().keepContentAfterProcessing(true)));
    System.out.println(asset.id());
  }
}
```

### curl

```bash
curl -X POST "https://api.calmlens.com/v1/projects/$CALMLENS_PROJECT_ID/assets?name=cat.jpg&contentType=image%2Fjpeg&queue=true&visibility=private&processingOptions=%7B%22keepContentAfterProcessing%22%3Atrue%7D" \
  -H "X-Api-Key: $CALMLENS_API_KEY" \
  -H "X-CalmLens-Upload: content" \
  -H "Content-Type: image/jpeg" \
  --data-binary @cat.jpg
```

Create a webhook in [Webhooks & connectors](/portal/connectors) for [`asset.approved`](/docs/api/events#event-asset-approved), [`asset.rejected`](/docs/api/events#event-asset-rejected) and [`asset.failed`](/docs/api/events#event-asset-failed). Verify each request's signature before handling it, then match `data.assetId` to the ID saved above.

**TypeScript Server SDK**

```typescript
import { CalmLensError, parseWebhookEvent } from "@calmlens/sdk/server";

export async function POST(request: Request): Promise<Response> {
  try {
    const event = await parseWebhookEvent({
      payload: await request.text(), headers: request.headers,
      secret: (process.env.CALMLENS_WEBHOOK_SECRET ?? ""),
    });
    // Queue work keyed by event.id before acknowledging delivery.
    return new Response(null, { status: 204 });
  } catch (error) {
    if (CalmLensError.isCalmLensError(error)) return new Response(null, { status: 401 });
    throw error;
  }
}
```

**JavaScript Server SDK**

```javascript
import { CalmLensError, parseWebhookEvent } from "@calmlens/sdk/server";

/** @param {Request} request */
export async function POST(request) {
  try {
    const event = await parseWebhookEvent({
      payload: await request.text(), headers: request.headers,
      secret: (process.env.CALMLENS_WEBHOOK_SECRET ?? ""),
    });
    // Queue work keyed by event.id before acknowledging delivery.
    return new Response(null, { status: 204 });
  } catch (error) {
    if (CalmLensError.isCalmLensError(error)) return new Response(null, { status: 401 });
    throw error;
  }
}
```

**python**

```python
import os

from calmlens import WebhookVerificationError, parse_webhook_event

def handle_webhook(raw_body: bytes, headers) -> int:
    try:
        # Verifies the signature of the exact raw body, then parses it.
        event = parse_webhook_event(
            raw_body,
            headers=headers,
            secret=os.environ["CALMLENS_WEBHOOK_SECRET"],
        )
    except WebhookVerificationError:
        return 401
    # Queue the work, keyed by event["id"], then respond quickly.
    return 204
```

**go**

```go
package main

import (
    "log"
    "net/http"
    "os"

    calmlens "github.com/CalmLens/sdks/go"
)

func handleWebhook(w http.ResponseWriter, r *http.Request) {
	// Reads the raw body (up to 1 MiB), verifies it, then decodes the event.
	event, err := calmlens.VerifyWebhookRequest(r, os.Getenv("CALMLENS_WEBHOOK_SECRET"))
	if err != nil {
		http.Error(w, "Invalid webhook", http.StatusUnauthorized)
		return
	}
	// Queue the work, keyed by event.ID, then respond quickly.
	log.Println(event.Type, event.ID)
	w.WriteHeader(http.StatusNoContent)
}

func main() {
    http.HandleFunc("/webhooks", handleWebhook)
    if err := http.ListenAndServe(":8080", nil); err != nil { panic(err) }
}

```

**ruby**

```ruby
require 'calmlens'
require 'json'

def handle_webhook(raw_body, headers)
  valid = CalmLens.verify_webhook_signature(
    payload: raw_body,
    signature: headers.fetch('x-calmlens-signature', ''),
    timestamp: headers.fetch('x-calmlens-timestamp', ''),
    secret: ENV.fetch('CALMLENS_WEBHOOK_SECRET')
  )
  return 401 unless valid
  event = JSON.parse(raw_body)
  # Queue work keyed by event['id'], then respond quickly.
  204
end

```

**rust**

```rust
use axum::{body::Bytes, http::{HeaderMap, StatusCode}};

// An axum handler: take the raw body as Bytes, never a parsed Json<_> extractor.
async fn calmlens_webhook(headers: HeaderMap, body: Bytes) -> StatusCode {
    let secret = std::env::var("CALMLENS_WEBHOOK_SECRET").unwrap_or_default();
    match calmlens::construct_webhook_event(&body, &headers, &secret) {
        Ok(event) => {
            // Queue work keyed by event.id, then respond quickly.
            println!("{} {:?}", event.event_type, event.data.asset_id);
            StatusCode::NO_CONTENT
        }
        Err(error) if error.code() == &calmlens::ErrorCode::InvalidWebhookSignature => StatusCode::UNAUTHORIZED,
        Err(_) => StatusCode::BAD_REQUEST,
    }
}

```

**java**

```java
import com.calmlens.CalmLensServerClient;
import com.calmlens.CalmLensException;
import com.calmlens.ErrorCodes;
import com.calmlens.Models.WebhookEventPayload;
import java.util.Map;

public class Example {
  static int handleWebhook(byte[] rawBody, Map<String, String> headers) {
    WebhookEventPayload event;
    try {
      event = CalmLensServerClient.parseWebhookEvent(
          rawBody,
          headers.get(CalmLensServerClient.WEBHOOK_SIGNATURE_HEADER),
          headers.get(CalmLensServerClient.WEBHOOK_TIMESTAMP_HEADER),
          System.getenv("CALMLENS_WEBHOOK_SECRET"));
    } catch (CalmLensException invalid) {
      return invalid.getCode().equals(ErrorCodes.SDK_INVALID_WEBHOOK_SIGNATURE) ? 401 : 400;
    }
    // Queue work keyed by event.id(), then respond quickly.
    System.out.println(event.type() + " " + event.data().assetId());
    return 204;
  }
}

```

See [Webhooks](/docs/guides/webhooks) for connector setup, signature verification and delivery retries. Keep content unpublished until it is approved.

## Waiting for the decision

Choose either flow independently of the upload mode:

| Flow              | SDK                        | Direct HTTP                 | Result                                                                                                                                                                                                          |
| ----------------- | -------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Moderate in place | `client.assets.classify()` | `queue=false` (the default) | Wait for classification and read the decision from the response.                                                                                                                                                |
| Queue moderation  | `client.assets.create()`   | `queue=true`                | Receive an asset ID while classification runs, then get the decision through an [`asset.approved`](/docs/api/events#event-asset-approved) or [`asset.rejected`](/docs/api/events#event-asset-rejected) webhook. |

Text and images support both flows. Audio and video always run asynchronously and return with `classification.moderation.decision` `pending`, even with `queue=false`. A multipart upload uses the queue mode chosen when it starts: CalmLens saves it with the upload, so whichever client sends the final part gets the same behaviour. See [Queue mode](/docs/guides/multipart-uploads#queue-mode).

For queued uploads, save the asset ID and [configure a webhook](/docs/guides/webhooks) for [`asset.approved`](/docs/api/events#event-asset-approved), [`asset.rejected`](/docs/api/events#event-asset-rejected) and [`asset.failed`](/docs/api/events#event-asset-failed). Keep content unpublished until `classification.moderation.decision` is `approved`; a successful upload alone is not a moderation decision.

Completed assets carry `classification.moderation`, described in [Report format](/docs/guides/classification-results). Check each category's `status` before reading `score`.

See [Asset lifecycle](/docs/guides/asset-lifecycle) for processing, moderation and content states, and [Delivering assets](/docs/guides/delivering-assets) to serve asset content, create signed URLs for private assets, and apply lenses.

### Fallback when webhooks are unavailable

If your integration cannot receive webhooks, retrieve the asset periodically with `client.assets.retrieve({ assetId })`. Use `client.assets.waitForDecision()` for SDK polling with a timeout, or manage the polling loop yourself; a single retrieval call does not wait for classification.

Start with a short delay, increase it between checks using exponential backoff, and cap the interval. Set an overall timeout so checks cannot continue indefinitely. Stop when `classification.moderation.decision` is `approved` or `rejected`, or `classification.status` is `failed`; keep content unpublished while `classification.moderation.decision` is `pending`. If the timeout expires, treat the result as unresolved and keep the content unpublished.

`client.assets.retrieve({ assetId })`

## Upload modes

Every upload mode uses [`POST /projects/:projectId/assets`](/docs/api/assets#create-asset). The request body decides the mode:

| You have                                      | Use                                                                            | Request body                                                                                                                                 |
| --------------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
| A file, buffer or stream                      | [Direct binary upload](/docs/guides/classifying-assets#direct-binary-upload)   | The file's bytes, with `X-CalmLens-Upload: content`                                                                                          |
| Base64 content or bytes inside a JSON request | [JSON upload](/docs/guides/classifying-assets#json-upload-with-base64-content) | JSON with `content.base64`                                                                                                                   |
| Text you have already extracted               | [Plain text upload](/docs/guides/classifying-assets#plain-text-upload)         | JSON with `content.plainText`                                                                                                                |
| A public web page or file URL                 | [Remote URL upload](/docs/guides/classifying-assets#remote-url-upload)         | JSON with `url`                                                                                                                              |
| A large file, or one you produce in chunks    | [Multipart upload](/docs/guides/classifying-assets#multipart-upload)           | The first part's bytes, with `X-CalmLens-Upload: part`, then [`POST /assets/:assetId/part`](/docs/api/assets#upload-asset-part) for the rest |

The SDKs choose the mode for you: they stream files with direct binary or multipart uploads and send other inputs as JSON.

### Direct binary upload

For direct HTTP integrations, send the unencoded file as the request body with the file's `Content-Type` and the `X-CalmLens-Upload: content` header. Asset fields go in query parameters. The header is what marks the body as a file, so a JSON file is uploaded as a file rather than read as a request. See [`POST /projects/:projectId/assets`](/docs/api/assets#upload-asset-file) for the complete request.

### JSON upload

Without the `X-CalmLens-Upload` header, the body is JSON: base64 content, plain text or a remote URL.

- Direct image uploads must be smaller than 10MB.
- All other file types support up to 5GB.
- The request body can contain `content.base64`, `content.plainText`, or `url`.

#### JSON upload with base64 content

```bash
curl -X POST \
  'https://api.calmlens.com/v1/projects/your-project-id/assets?queue=true' \
  -H 'X-Api-Key: your-api-key' \
  -H "Content-Type: application/json" \
  --data '{
  "name": "cat.png",
  "description": "Homepage hero image",
  "visibility": "private",
  "processingOptions": {
    "keepContentAfterProcessing": true
  },
  "content": {
    "base64": "your-base64-file",
    "type": "image/png"
  }
}'
```

#### Plain text upload

```bash
curl -X POST \
  'https://api.calmlens.com/v1/projects/your-project-id/assets?queue=true' \
  -H 'X-Api-Key: your-api-key' \
  -H "Content-Type: application/json" \
  --data '{
  "name": "notes.txt",
  "content": {
    "plainText": "This is the extracted text to moderate.",
    "type": "text/plain"
  }
}'
```

#### Remote URL upload

Remote URLs must use HTTP or HTTPS. Private IP addresses, localhost names and URL credentials are rejected, and each redirect is checked. Downloads time out after 30 seconds and are limited to 10 MB for images and 20 MB for other files. Use multipart upload for larger files.

```bash
curl -X POST \
  'https://api.calmlens.com/v1/projects/your-project-id/assets?queue=true' \
  -H 'X-Api-Key: your-api-key' \
  -H "Content-Type: application/json" \
  --data '{
  "name": "landing-page",
  "url": "https://example.com"
}'
```

### Multipart upload

The SDK chooses multipart uploads when needed. For manual chunks, ordering, finalisation and recovery, see [Multipart uploads](/docs/guides/multipart-uploads).

## Retrying uploads

Use an `Idempotency-Key` when retrying uploads; SDKs generate one automatically. See [Retrying requests safely](/docs/guides/error-handling#retrying-requests-safely) for replay behaviour and the distinction from `externalId`.

## Asset types and limits

CalmLens detects the asset type from the content's MIME type.

| Asset type            | How it is detected                  | Typical input              | Upload limit | Guide                                                  |
| --------------------- | ----------------------------------- | -------------------------- | ------------ | ------------------------------------------------------ |
| `image`               | `image/*` MIME types                | PNG, JPEG, WebP, GIF, AVIF | 20MB         | [OCR and image reports](/docs/guides/ocr)              |
| `video` (preview)     | `video/*` MIME types                | MP4, MOV, WebM             | 5GB          | [Audio and video](/docs/guides/audio-and-video)        |
| `audio` (preview)     | `audio/*` MIME types                | MP3, WAV, M4A              | 5GB          | [Audio and video](/docs/guides/audio-and-video)        |
| `plaintext-document`  | Plain text MIME types               | TXT, JSON, CSV, YAML       | 5GB          | [Reading reports](/docs/guides/classification-results) |
| `multimedia-document` | Supported document MIME types       | PDF, DOCX, DOC, RTF, ODT   | 5GB          | [Reading reports](/docs/guides/classification-results) |
| `website` (preview)   | `text/html` MIME type or URL upload | HTML or remote website URL | 5GB          | [Website moderation](/docs/guides/websites)            |
| `file`                | Fallback for other file types       | Other binary files         | 5GB          | [Reading reports](/docs/guides/classification-results) |

Image uploads are also constrained by a maximum image dimension of `12,000 x 12,000` pixels.

## Processing features

Optional analyses are chosen per upload in `processingOptions.features`, keyed by feature. Each feature takes an object with `enabled` and any options of its own. There is no project setting for them. Leaving a feature out or sending `enabled: false` means it does not run.

| Feature                                            | What it does                                                                        | Stage               | Options                    | Billed per image |
| -------------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------- | -------------------------- | ---------------- |
| [`ocr`](/docs/guides/ocr)                          | Stores extracted text on the image and adds a text moderation pass.                 | Generally available | `enabled`                  | OCR              |
| [`personDetection`](/docs/guides/people-and-faces) | Locates people in images.                                                           | Preview             | `enabled`                  | Person detection |
| [`faceDetection`](/docs/guides/people-and-faces)   | Locates faces, with an estimated age range for each when `ageEstimation` is `true`. | Preview             | `enabled`, `ageEstimation` | Face detection   |

**TypeScript Server SDK**

```typescript
import { readFile } from "node:fs/promises";

const asset = await client.assets.classify({
  name: "photo.jpg",
  content: { buffer: await readFile("./photo.jpg"), type: "image/jpeg" },
  processingOptions: {
    features: {
      personDetection: { enabled: true },
      faceDetection: { enabled: true, ageEstimation: true },
    },
  },
});

const faces = asset.classification.faceDetection;
if (faces?.status === "evaluated") {
  for (const face of faces.faces) console.log(face.box, face.ageRange);
}
```

**python**

```python
with open("photo.jpg", "rb") as content:
    asset = client.assets.classify({
        "name": "photo.jpg",
        "content": {"data": content, "type": "image/jpeg"},
        "processingOptions": {
            "features": {
                "personDetection": {"enabled": True},
                "faceDetection": {"enabled": True, "ageEstimation": True},
            },
        },
    })

faces = asset["classification"].get("faceDetection")
if faces and faces["status"] == "evaluated":
    for face in faces["faces"]:
        print(face["box"], face.get("ageRange"))
```

**go**

```go
package main

import (
    "context"
    "fmt"
    "log"
    "os"
    calmlens "github.com/CalmLens/sdks/go"
)

func example(client *calmlens.ServerClient) {
    content, err := os.Open("photo.jpg")
    if err != nil { log.Fatal(err) }
    defer content.Close()
    asset, err := client.Assets.Classify(context.Background(), calmlens.SubmitAssetOptions{
        Name:    "photo.jpg",
        Content: &calmlens.Content{Data: content, Type: "image/jpeg"},
        ProcessingOptions: &calmlens.ProcessingOptions{Features: &calmlens.ProcessingFeatures{
            PersonDetection: &calmlens.PersonDetectionFeatureOptions{Enabled: true},
            FaceDetection:   &calmlens.FaceDetectionFeatureOptions{Enabled: true, AgeEstimation: calmlens.Ptr(true)},
        }},
    })
    if err != nil { log.Fatal(err) }
    if asset.Classification.FaceDetection != nil {
        for _, face := range asset.Classification.FaceDetection.Faces {
            fmt.Println(face.Box, face.AgeRange)
        }
    }
}
```

**JavaScript Server SDK**

```javascript
import { readFile } from "node:fs/promises";

const asset = await client.assets.classify({
  name: "photo.jpg",
  content: { buffer: await readFile("./photo.jpg"), type: "image/jpeg" },
  processingOptions: {
    features: {
      personDetection: { enabled: true },
      faceDetection: { enabled: true, ageEstimation: true },
    },
  },
});

const faces = asset.classification.faceDetection;
if (faces?.status === "evaluated") {
  for (const face of faces.faces) console.log(face.box, face.ageRange);
}
```

**ruby**

```ruby
asset = client.assets.classify(
  name: 'photo.jpg',
  content: {data: File.binread('photo.jpg'), type: 'image/jpeg'},
  processing_options: {features: {
    person_detection: {enabled: true},
    face_detection: {enabled: true, age_estimation: true}
  }}
)
faces = asset.dig('classification', 'faceDetection')
faces['faces'].each { |face| puts face['box'], face['ageRange'] } if faces && faces['status'] == 'evaluated'
```

**rust**

```rust
use calmlens::{models::*, ServerClient};

async fn example(client: &ServerClient) -> Result<(), Box<dyn std::error::Error>> {
    let bytes = std::fs::read("photo.jpg")?;
    let asset = client.assets().classify(SubmitAsset {
        processing_options: Some(ProcessingOptions {
            features: Some(ProcessingFeatures {
                person_detection: Some(FeatureOptions::enabled()),
                face_detection: Some(FaceDetectionOptions { age_estimation: Some(true), ..FaceDetectionOptions::enabled() }),
                ..Default::default()
            }),
            ..Default::default()
        }),
        ..SubmitAsset::new("photo.jpg", Content::bytes(bytes, "image/jpeg"))
    }).await?;
    let faces = asset.classification.face_detection.and_then(|f| f.faces);
    for face in faces.unwrap_or_default() {
        println!("{:?} {:?}", face.bounding_box, face.age_range);
    }
    Ok(())
}
```

**java**

```java
import com.calmlens.CalmLensServerClient;
import com.calmlens.Models.*;

public class Example {
  public static void example(CalmLensServerClient client) throws Exception {
    byte[] bytes = java.nio.file.Files.readAllBytes(java.nio.file.Path.of("photo.jpg"));
    Asset asset = client.assets.classify(new SubmitAsset("photo.jpg", Content.bytes(bytes, "image/jpeg"))
        .processingOptions(new ProcessingOptions().features(new ProcessingFeatures()
            .personDetection(new FeatureOptions(true))
            .faceDetection(new FaceDetectionOptions().enabled(true).ageEstimation(true)))));
    FaceDetectionResult faces = asset.classification().faceDetection();
    if ("evaluated".equals(faces.status())) faces.faces().forEach(face -> System.out.println(face.box() + " " + face.ageRange()));
  }
}
```

**curl**

```bash
curl -X POST "https://api.calmlens.com/v1/projects/$CALMLENS_PROJECT_ID/assets?name=photo.jpg&contentType=image%2Fjpeg&queue=false&processingOptions=%7B%22features%22%3A%7B%22personDetection%22%3A%7B%22enabled%22%3Atrue%7D%2C%22faceDetection%22%3A%7B%22enabled%22%3Atrue%2C%22ageEstimation%22%3Atrue%7D%7D%7D"   -H "X-Api-Key: $CALMLENS_API_KEY"   -H "X-CalmLens-Upload: content"   -H "Content-Type: image/jpeg"   --data-binary @photo.jpg
```

- Unknown features and unknown options are rejected, so a typo fails the request instead of being ignored.
- Features apply to images, including images extracted from multimedia documents and websites, which inherit the parent's processing options. Other asset types ignore them.
- The features an asset was uploaded with are saved in `asset.processingOptions.features`. Retrying the asset runs the same features again.
- Person and face detection results are returned in `classification.personDetection` and `classification.faceDetection` and never change the moderation decision.

## Asset metadata

Attach custom fields to your uploads. See [Asset metadata](/docs/guides/organising-assets#asset-metadata) for field definitions and examples.

## Folders

Pass a `folderId` to organise an upload. See [Folders](/docs/guides/organising-assets#folders) for creating, moving and managing a folder tree.
