# Delivering assets

The CalmLens CDN serves stored asset content and previews, so your app can show moderated files without hosting them itself. This guide covers asset URLs, signed URLs for private assets, and caching. For the signing endpoint and URL formats, see the [CDN reference](/docs/api/cdn).

CDN URLs live under:

```text
https://cdn.calmlens.com/v1
```

## Before you serve a file

- **Retain it:** set `processingOptions.keepContentAfterProcessing: true` when uploading.
- **Check the decision:** rejected, failed, removed and safety-held content cannot be served. Enable [Block assets until moderation finishes](/portal/settings#block-unmoderated-assets) to block pending content too.
- **Choose access:** use public visibility for public files or a signed URL for private files in browsers.

## Asset URLs

Asset content is served from [`GET /assets/:projectId/:assetIdentifier/content`](/docs/api/cdn#original-content).

Asset previews are served from [`GET /assets/:projectId/:assetIdentifier/preview`](/docs/api/cdn#previews).

`assetIdentifier` can be the asset ID or the asset's `externalId`. The asset must have finished uploading and its content must have been retained with `processingOptions.keepContentAfterProcessing: true`. Public assets can then be requested directly.

Rejected assets, assets whose processing failed (`error`), safety-held assets and assets with removed content are never served. Assets whose moderation is still `pending` are served by default; enable **Block assets until moderation finishes** in the project settings (`settings.blockUnmoderatedAssets`) to serve only moderated content. See [Delivery while moderation runs](/docs/guides/asset-lifecycle#delivery-while-moderation-runs).

```bash
curl -X GET "https://cdn.calmlens.com/v1/assets/your-project-id/your-asset-id/content"
```

Private assets require one of the following:

- An authenticated CalmLens browser session belonging to the asset owner.
- A short-lived [signed URL](#signed-urls).

For direct private reads, enable **Download assets** (`assets:download`) on a project API key and send it in the `X-Api-Key` header. This permission only grants access within that key's project.

### HEIC and HEIF previews

The preview endpoint automatically converts HEIC and HEIF image assets to WebP so they can be displayed in browsers that do not support Apple's source formats. This applies to `image/heic`, `image/heif`, and their sequence variants. Converted previews fit within 1920 × 1920 pixels, preserve their aspect ratio, and are never enlarged.

```text
/assets/:projectId/:assetIdentifier/preview
```

The content endpoint continues to serve the original stored file. Lenses and direct transforms also work on the browser-friendly preview.

## Signed URLs

Signed URLs let untrusted clients access one private resource for a limited time without receiving an API key or application session. Prefer them for browsers and other clients you do not control.

Create a signed URL from the public API:

```bash
curl -X POST "https://api.calmlens.com/v1/assets/your-asset-id/signed-url" \
  -H "X-Api-Key: your-api-key" \
  -H "Content-Type: application/json" \
  --data '{
  "resource": "content",
  "expiresInSeconds": 900
}'
```

The response contains:

```json
{
  "url": "https://cdn.calmlens.com/v1/assets/.../content?exp=...&sig=...",
  "expiresAt": 1760000000000
}
```

With your SDK:

**JavaScript**

```javascript
const signedUrl = await client.assets.createSignedUrl({
  assetId,
  resource: "content",
  expiresInSeconds: 900,
});
```

**python**

```python
import os
import json
from calmlens import CalmLensClient

client = CalmLensClient(
    api_key=os.environ["CALMLENS_API_KEY"],
    project_id="your-project-id",
)

options = json.loads("{\"resource\":\"content\",\"expiresInSeconds\":900,\"assetId\":\"your-asset-id\"}")
response = client.assets.create_signed_url(options)
print(response)
```

**go**

```go
package main

import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "os"

    calmlens "github.com/Censkh/CalmLens/packages/go-sdk"
)

func main() {
    client, err := calmlens.NewClient(calmlens.Options{
        APIKey: os.Getenv("CALMLENS_API_KEY"),
        ProjectID: "your-project-id",
    })
    if err != nil { log.Fatal(err) }

    var options calmlens.SignedURLOptions
    if err := json.Unmarshal([]byte("{\"resource\":\"content\",\"expiresInSeconds\":900}"), &options); err != nil { log.Fatal(err) }
    options.AssetID = "your-asset-id"
    response, err := client.Assets.CreateSignedURL(context.Background(), options)
    if err != nil { log.Fatal(err) }
    fmt.Println(response)
}
```

**ruby**

```ruby
require 'calmlens'

client = CalmLens::Client.new(api_key: ENV.fetch('CALMLENS_API_KEY'), project_id: ENV.fetch('CALMLENS_PROJECT_ID'))
result = client.assets.create_signed_url(resource: "content", expires_in_seconds: 900, asset_id: "your-asset-id")
puts result
client.close
```

**rust**

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

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new(std::env::var("CALMLENS_API_KEY")?, std::env::var("CALMLENS_PROJECT_ID")?)?;
    let result = client.assets().create_signed_url(SignedUrlOptions { resource: Some("content".into()), expires_in_seconds: Some(900), asset_id: "your-asset-id".into(), ..Default::default() }).await?;
    println!("{result:?}");
    Ok(())
}
```

**java**

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

public class Example {
  public static void main(String[] args) {
    var client = new CalmLensClient(System.getenv("CALMLENS_API_KEY"), System.getenv("CALMLENS_PROJECT_ID"));
    var result = client.assets.createSignedUrl(new SignedUrlOptions().resource("content").expiresInSeconds(900L).assetId("your-asset-id"));
    System.out.println(result);
  }
}
```

**curl**

```sh
curl -X POST "https://api.calmlens.com/v1/assets/$ASSET_ID/signed-url" \
  -H "X-Api-Key: $CALMLENS_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{
  "resource": "content",
  "expiresInSeconds": 900
}'
```

Supported request body fields:

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `resource` | `"content"` or `"preview"` | `"content"` | Which CDN resource to sign. |
| `lens` | `string` | omitted | Optional lens identifier, such as `thumbnail`. |
| `transform` | `string` | omitted | Optional direct transform selector, such as `imageTransform[format=png,width=1024]`. |
| `expiresInSeconds` | `number` | `900` | Signed URL lifetime, from 1 second to 24 hours. |

Provide either `lens` or `transform`, never both. The signature is scoped to the project, asset identifier, resource, selected lens or transform, and expiry. Changing any of those values invalidates the URL.

## Lenses and transforms

Use [Lenses & Transformation](/docs/guides/lenses) for named lenses, private lens URLs, direct image transforms, supported options, and versioned custom definitions.

## Caching and availability

Public derived responses are cacheable. Private responses remain private and require authorization on each unsigned request. Signed URL parameters, selected resource, lens or transform, asset visibility, asset status, and lens version all participate in authorization or cache identity.

Changing a signed URL, transform, or lens selector invalidates its signature. Every CDN request, including one answered from cache, checks the asset's status, visibility and stored content first, and rejecting an asset, placing a safety hold, removing its content or deleting it purges cached copies across the CDN, so delivery stops within seconds in every region. Browsers and other HTTP caches outside CalmLens may keep a copy they already downloaded until its `Cache-Control` lifetime ends. Requests for unavailable private assets generally return `404` to avoid revealing their existence; assets blocked by their status return [`403 asset/not-available`](/docs/api/cdn#error-asset-not-available).
