# Build an upload component

Build a file field that uploads photos from the browser straight to CalmLens, tells the uploader whether their photo was accepted, and lets your server publish only what is allowed. Your server signs each upload, so files never pass through it and your API key never reaches the browser.

## 1. Create an upload preset

In [Upload presets](/portal/upload-presets), create a preset for the field:

| Setting                           | Value                                                                          |
| --------------------------------- | ------------------------------------------------------------------------------ |
| Who can upload                    | **Users your server approves**, so every upload needs a token from your server |
| File types                        | `image/*`, or the formats your app displays                                    |
| Maximum file size                 | The largest photo you accept                                                   |
| Keep content after moderation     | On, so you can serve the photo from the CDN                                    |
| Show the decision to the uploader | On, so the component can show the result                                       |

Copy the preset ID. [Upload presets](/docs/guides/upload-presets) explains every setting.

## 2. Sign upload tokens on your server

Add an endpoint that checks the signed-in user, creates a record for the upload they're starting (for example a draft post), and returns a token for it. `client.uploadTokens.sign({ uploadPresetId, externalId, expiresInSeconds })` signs the token locally with your API key. Set `externalId` to the record's ID: CalmLens stores it on the asset and sends it back in every webhook. An `externalId` can belong to only one asset in a project, so create a new record for each token, including when the user picks another file.

**JavaScript/TypeScript Server SDK**

```typescript
const { token, expiresAt } = await client.uploadTokens.sign({
  uploadPresetId: "your-upload-preset-id",
  externalId: "post-42",
  expiresInSeconds: 600,
});
```

**python**

```python
signed = client.upload_tokens.sign(
    upload_preset_id="your-upload-preset-id",
    external_id="post-42",
    expires_in_seconds=600,
)
print(signed["token"], signed["expiresAt"])
```

**go**

```go
package main

import (
    "context"
    "fmt"
    "log"

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

func example(client *calmlens.ServerClient) {

    signed, err := client.UploadTokens.Sign(context.Background(), calmlens.SignUploadTokenOptions{
        UploadPresetID: "your-upload-preset-id",
        ExternalID: calmlens.Ptr("post-42"),
        ExpiresInSeconds: 600,
    })
    if err != nil { log.Fatal(err) }
    fmt.Println(signed.Token, signed.ExpiresAt)
}
```

**ruby**

```ruby
signed = client.upload_tokens.sign(upload_preset_id: "your-upload-preset-id", external_id: "post-42", expires_in_seconds: 600)
puts signed["token"], signed["expiresAt"]
```

**rust**

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

fn example(client: &ServerClient) -> Result<(), Box<dyn std::error::Error>> {
    let signed = client.upload_tokens().sign(SignUploadToken { external_id: Some("post-42".into()), expires_in_seconds: Some(600), ..SignUploadToken::new("your-upload-preset-id") })?;
    println!("{} {}", signed.token, signed.expires_at);
    Ok(())
}
```

**java**

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

public class Example {
  public static void example(CalmLensServerClient client) {
    var signed = client.uploadTokens.sign(new SignUploadToken("your-upload-preset-id").externalId("post-42").expiresInSeconds(600));
    System.out.println(signed.token() + " " + signed.expiresAt());
  }
}
```

Return `{ token }` from the endpoint. Each token uploads one file and stops starting uploads after 10 minutes by default; see [Upload tokens](/docs/guides/upload-presets#upload-tokens).

## 3. Add the upload component

Install `@calmlens/sdk` in your front end. The component below fetches a token from your endpoint, uploads the chosen file with `CalmLensAppClient`, then checks the upload until it has a decision:

**React**

```tsx
import { CalmLensAppClient, CalmLensError } from "@calmlens/sdk/app";
import { type ChangeEvent, useState } from "react";

const MESSAGES: Record<string, string> = {
  uploading: "Uploading…",
  checking: "Checking your photo…",
  pending: "Uploaded. We'll show it once it has been checked.",
  allowed: "Uploaded.",
  limited: "Uploaded.",
  "age-restricted": "Uploaded. It will only be shown to people who can see sensitive content.",
  blocked: "This photo can't be used because it may break our community guidelines.",
  purged: "This photo can't be used because it breaks our community guidelines.",
  "upload-preset/too-large": "Choose a smaller photo.",
  "upload-preset/content-type-not-allowed": "Choose a JPEG, PNG or WebP photo.",
  failed: "We couldn't upload this photo. Please try again.",
};

type Upload = { assetId: string; status: string };

async function uploadPhoto(file: File, onStage: (stage: string) => void): Promise<Upload> {
  onStage("uploading");
  const response = await fetch("/api/upload-token", { method: "POST" });
  if (!response.ok) throw new Error("Upload refused");
  const { token } = await response.json();
  const client = new CalmLensAppClient({ uploadPresetId: "your-upload-preset-id", token });

  let asset = await client.assets.create({
    name: file.name,
    content: { data: file, type: file.type },
  });
  onStage("checking");
  for (let delay = 1000; asset.status === "pending" && delay <= 8000; delay *= 2) {
    await new Promise((resolve) => setTimeout(resolve, delay));
    asset = await client.assets.retrieve({ assetId: asset.id });
  }
  return { assetId: asset.id, status: asset.status ?? "pending" };
}

export function PhotoUpload({ onUploaded }: { onUploaded: (upload: Upload) => void }) {
  const [stage, setStage] = useState<string | null>(null);
  const busy = stage === "uploading" || stage === "checking";

  async function handleChange(event: ChangeEvent<HTMLInputElement>) {
    const file = event.target.files?.[0];
    if (!file) return;
    try {
      const upload = await uploadPhoto(file, setStage);
      setStage(upload.status);
      if (upload.status !== "blocked" && upload.status !== "purged") onUploaded(upload);
    } catch (error) {
      const known = CalmLensError.isCalmLensError(error) && MESSAGES[error.code];
      setStage(known ? error.code : "failed");
    }
  }

  return (
    <div>
      <label>
        Photo <input type="file" accept="image/*" disabled={busy} onChange={handleChange} />
      </label>
      {stage && busy && <progress aria-label={MESSAGES[stage]} />}
      <p role="status">{stage ? MESSAGES[stage] : ""}</p>
    </div>
  );
}
```

**Vue**

```vue
<script setup lang="ts">
import { CalmLensAppClient, CalmLensError } from "@calmlens/sdk/app";
import { computed, ref } from "vue";

type Upload = { assetId: string; status: string };
const emit = defineEmits<{ uploaded: [upload: Upload] }>();

const MESSAGES: Record<string, string> = {
  uploading: "Uploading…",
  checking: "Checking your photo…",
  pending: "Uploaded. We'll show it once it has been checked.",
  allowed: "Uploaded.",
  limited: "Uploaded.",
  "age-restricted": "Uploaded. It will only be shown to people who can see sensitive content.",
  blocked: "This photo can't be used because it may break our community guidelines.",
  purged: "This photo can't be used because it breaks our community guidelines.",
  "upload-preset/too-large": "Choose a smaller photo.",
  "upload-preset/content-type-not-allowed": "Choose a JPEG, PNG or WebP photo.",
  failed: "We couldn't upload this photo. Please try again.",
};

const stage = ref<string | null>(null);
const busy = computed(() => stage.value === "uploading" || stage.value === "checking");

async function uploadPhoto(file: File): Promise<Upload> {
  stage.value = "uploading";
  const response = await fetch("/api/upload-token", { method: "POST" });
  if (!response.ok) throw new Error("Upload refused");
  const { token } = await response.json();
  const client = new CalmLensAppClient({ uploadPresetId: "your-upload-preset-id", token });

  let asset = await client.assets.create({ name: file.name, content: { data: file, type: file.type } });
  stage.value = "checking";
  for (let delay = 1000; asset.status === "pending" && delay <= 8000; delay *= 2) {
    await new Promise((resolve) => setTimeout(resolve, delay));
    asset = await client.assets.retrieve({ assetId: asset.id });
  }
  return { assetId: asset.id, status: asset.status ?? "pending" };
}

async function handleChange(event: Event) {
  const file = (event.target as HTMLInputElement).files?.[0];
  if (!file) return;
  try {
    const upload = await uploadPhoto(file);
    stage.value = upload.status;
    if (upload.status !== "blocked" && upload.status !== "purged") emit("uploaded", upload);
  } catch (error) {
    const known = CalmLensError.isCalmLensError(error) && MESSAGES[error.code];
    stage.value = known ? error.code : "failed";
  }
}
</script>

<template>
  <div>
    <label>Photo <input type="file" accept="image/*" :disabled="busy" @change="handleChange" /></label>
    <progress v-if="busy" :aria-label="MESSAGES[stage!]" />
    <p role="status">{{ stage ? MESSAGES[stage] : "" }}</p>
  </div>
</template>
```

**Svelte**

```svelte
<script lang="ts">
  import { CalmLensAppClient, CalmLensError } from "@calmlens/sdk/app";

  let { onUploaded }: { onUploaded: (upload: { assetId: string; status: string }) => void } = $props();
  let stage = $state<string | null>(null);
  const busy = $derived(stage === "uploading" || stage === "checking");

  const MESSAGES: Record<string, string> = {
    uploading: "Uploading…",
    checking: "Checking your photo…",
    pending: "Uploaded. We'll show it once it has been checked.",
    allowed: "Uploaded.",
    limited: "Uploaded.",
    "age-restricted": "Uploaded. It will only be shown to people who can see sensitive content.",
    blocked: "This photo can't be used because it may break our community guidelines.",
    purged: "This photo can't be used because it breaks our community guidelines.",
    "upload-preset/too-large": "Choose a smaller photo.",
    "upload-preset/content-type-not-allowed": "Choose a JPEG, PNG or WebP photo.",
    failed: "We couldn't upload this photo. Please try again.",
  };

  async function uploadPhoto(file: File) {
    stage = "uploading";
    const response = await fetch("/api/upload-token", { method: "POST" });
    if (!response.ok) throw new Error("Upload refused");
    const { token } = await response.json();
    const client = new CalmLensAppClient({ uploadPresetId: "your-upload-preset-id", token });

    let asset = await client.assets.create({ name: file.name, content: { data: file, type: file.type } });
    stage = "checking";
    for (let delay = 1000; asset.status === "pending" && delay <= 8000; delay *= 2) {
      await new Promise((resolve) => setTimeout(resolve, delay));
      asset = await client.assets.retrieve({ assetId: asset.id });
    }
    return { assetId: asset.id, status: asset.status ?? "pending" };
  }

  async function handleChange(event: Event) {
    const file = (event.currentTarget as HTMLInputElement).files?.[0];
    if (!file) return;
    try {
      const upload = await uploadPhoto(file);
      stage = upload.status;
      if (upload.status !== "blocked" && upload.status !== "purged") onUploaded(upload);
    } catch (error) {
      const known = CalmLensError.isCalmLensError(error) && MESSAGES[error.code];
      stage = known ? error.code : "failed";
    }
  }
</script>

<div>
  <label>Photo <input type="file" accept="image/*" disabled={busy} onchange={handleChange} /></label>
  {#if busy}<progress aria-label={MESSAGES[stage ?? ""]}></progress>{/if}
  <p role="status">{stage ? MESSAGES[stage] : ""}</p>
</div>
```

**Plain JavaScript**

```javascript
import { CalmLensAppClient, CalmLensError } from "@calmlens/sdk/app";

const MESSAGES = {
  uploading: "Uploading…",
  checking: "Checking your photo…",
  pending: "Uploaded. We'll show it once it has been checked.",
  allowed: "Uploaded.",
  limited: "Uploaded.",
  "age-restricted": "Uploaded. It will only be shown to people who can see sensitive content.",
  blocked: "This photo can't be used because it may break our community guidelines.",
  purged: "This photo can't be used because it breaks our community guidelines.",
  "upload-preset/too-large": "Choose a smaller photo.",
  "upload-preset/content-type-not-allowed": "Choose a JPEG, PNG or WebP photo.",
  failed: "We couldn't upload this photo. Please try again.",
};

async function uploadPhoto(file, onStage) {
  onStage("uploading");
  const response = await fetch("/api/upload-token", { method: "POST" });
  if (!response.ok) throw new Error("Upload refused");
  const { token } = await response.json();
  const client = new CalmLensAppClient({ uploadPresetId: "your-upload-preset-id", token });

  let asset = await client.assets.create({
    name: file.name,
    content: { data: file, type: file.type },
  });
  onStage("checking");
  for (let delay = 1000; asset.status === "pending" && delay <= 8000; delay *= 2) {
    await new Promise((resolve) => setTimeout(resolve, delay));
    asset = await client.assets.retrieve({ assetId: asset.id });
  }
  return { assetId: asset.id, status: asset.status ?? "pending" };
}

export function mountPhotoUpload(container, { onUploaded }) {
  const input = Object.assign(document.createElement("input"), { type: "file", accept: "image/*" });
  const label = document.createElement("label");
  label.append("Photo ", input);
  const progress = document.createElement("progress");
  progress.hidden = true;
  const message = document.createElement("p");
  message.setAttribute("role", "status");
  container.replaceChildren(label, progress, message);

  const show = (stage) => {
    const busy = stage === "uploading" || stage === "checking";
    input.disabled = busy;
    progress.hidden = !busy;
    progress.setAttribute("aria-label", MESSAGES[stage]);
    message.textContent = MESSAGES[stage];
  };

  input.addEventListener("change", async () => {
    const file = input.files?.[0];
    if (!file) return;
    try {
      const upload = await uploadPhoto(file, show);
      show(upload.status);
      if (upload.status !== "blocked" && upload.status !== "purged") onUploaded(upload);
    } catch (error) {
      const known = CalmLensError.isCalmLensError(error) && MESSAGES[error.code];
      show(known ? error.code : "failed");
    }
  });
}
```

How it works:

- `client.assets.create()` returns once the file is stored, so the component can move from **Uploading** to **Checking** while moderation runs. The client doesn't report byte-level progress by itself, so this component's progress bar is indeterminate. To show the bytes sent, pass `uploadFetch: createProgressFetch(onProgress)` from `@calmlens/sdk/ui` to `CalmLensAppClient`.
- `client.assets.retrieve({ assetId })` returns `status`, the moderation status, because the preset shows decisions to the uploader. The component checks with growing delays and stops after about 15 seconds; anything still `pending` is settled by your server's webhook.
- Errors the uploader can fix, such as [`413 upload-preset/too-large`](/docs/api/assets#post-asset-error-upload-preset-too-large) and [`415 upload-preset/content-type-not-allowed`](/docs/api/assets#post-asset-error-upload-preset-content-type-not-allowed), get their own message. Match on `error.code`, never on the message text. The other codes are listed under [Upload preset errors](/docs/guides/upload-presets#errors).

To wait for the decision in a single call instead, use `client.assets.classify()`: the component then shows one combined uploading and checking step.

To use a ready-made component instead, the SDK's [upload widget](/docs/guides/upload-widget) does all of this with drag and drop, several files, byte progress and retries.

## 4. Save the asset ID with the post

When the uploader submits the form, send the asset ID with it. On your server, retrieve the asset with `client.assets.retrieve({ assetId })` and check that its `externalId` is the draft you signed the token for, so a user can't attach someone else's upload. Store the asset ID and `classification.moderation.status` with the post.

The status in the browser is only for the uploader's feedback. Users can change what their browser sends, so publish from the status your server reads from CalmLens.

## 5. Receive the decision on your server

In [Webhooks & connectors](/portal/connectors), subscribe your endpoint to [`asset.allowed`](/docs/api/events#event-asset-allowed), [`asset.limited`](/docs/api/events#event-asset-limited), [`asset.age-restricted`](/docs/api/events#event-asset-age-restricted), [`asset.blocked`](/docs/api/events#event-asset-blocked), [`asset.purged`](/docs/api/events#event-asset-purged) and [`asset.failed`](/docs/api/events#event-asset-failed). Each event carries the asset's `externalId` and current status, so the handler can update the post without another request:

**TypeScript Server SDK**

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

const STATUS_EVENTS = new Set(["asset.allowed", "asset.limited", "asset.age-restricted", "asset.blocked", "asset.purged"]);

export async function POST(request: Request): Promise<Response> {
  let event;
  try {
    event = await parseWebhookEvent({
      payload: await request.text(), headers: request.headers,
      secret: (process.env.CALMLENS_WEBHOOK_SECRET ?? ""),
    });
  } catch (error) {
    if (CalmLensError.isCalmLensError(error)) return new Response(null, { status: 401 });
    throw error;
  }

  const asset = event.data.asset;
  if (asset?.externalId && STATUS_EVENTS.has(event.type)) {
    // Store the new status and re-render from it. Skip events older than the one you stored.
    await updateModerationStatus(asset.externalId, asset.classification.moderation.status, event.createdAt);
  } else if (asset?.externalId && event.type === "asset.failed") {
    await markModerationFailed(asset.externalId);
  }
  return new Response(null, { status: 204 });
}
```

**JavaScript Server SDK**

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

const STATUS_EVENTS = new Set(["asset.allowed", "asset.limited", "asset.age-restricted", "asset.blocked", "asset.purged"]);

/** @param {Request} request */
export async function POST(request) {
  let event;
  try {
    event = await parseWebhookEvent({
      payload: await request.text(), headers: request.headers,
      secret: (process.env.CALMLENS_WEBHOOK_SECRET ?? ""),
    });
  } catch (error) {
    if (CalmLensError.isCalmLensError(error)) return new Response(null, { status: 401 });
    throw error;
  }

  const asset = event.data.asset;
  if (asset?.externalId && STATUS_EVENTS.has(event.type)) {
    // Store the new status and re-render from it. Skip events older than the one you stored.
    await updateModerationStatus(asset.externalId, asset.classification.moderation.status, event.createdAt);
  } else if (asset?.externalId && event.type === "asset.failed") {
    await markModerationFailed(asset.externalId);
  }
  return new Response(null, { status: 204 });
}
```

**python**

```python
import os

from calmlens import WebhookVerificationError, parse_webhook_event

STATUS_EVENTS = {"asset.allowed", "asset.limited", "asset.age-restricted", "asset.blocked", "asset.purged"}

def handle_webhook(raw_body: bytes, headers) -> int:
    try:
        event = parse_webhook_event(
            raw_body,
            headers=headers,
            secret=os.environ["CALMLENS_WEBHOOK_SECRET"],
        )
    except WebhookVerificationError:
        return 401

    asset = event["data"].get("asset")
    external_id = asset.get("externalId") if asset else None
    if asset and external_id and event["type"] in STATUS_EVENTS:
        # Store the new status and re-render from it. Skip events older than the one you stored.
        status = asset["classification"]["moderation"]["status"]
        update_moderation_status(external_id, status, event["createdAt"])
    elif external_id and event["type"] == "asset.failed":
        mark_moderation_failed(external_id)
    return 204
```

**go**

```go
package main

import (
    "net/http"
    "os"

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

func handleWebhook(w http.ResponseWriter, r *http.Request) {
	event, err := calmlens.VerifyWebhookRequest(r, os.Getenv("CALMLENS_WEBHOOK_SECRET"))
	if err != nil {
		http.Error(w, "Invalid webhook", http.StatusUnauthorized)
		return
	}

	asset := event.Data.Asset
	if asset != nil && asset.ExternalID != nil {
		switch event.Type {
		case calmlens.EventAssetAllowed, calmlens.EventAssetLimited, calmlens.EventAssetAgeRestricted, calmlens.EventAssetBlocked, calmlens.EventAssetPurged:
			// Store the new status and re-render from it. Skip events older than the one you stored.
			updateModerationStatus(*asset.ExternalID, asset.Classification.Moderation.Status, event.CreatedAt)
		case calmlens.EventAssetFailed:
			markModerationFailed(*asset.ExternalID)
		}
	}
	w.WriteHeader(http.StatusNoContent)
}
```

**ruby**

```ruby
require 'calmlens'

STATUS_EVENTS = %w[asset.allowed asset.limited asset.age-restricted asset.blocked asset.purged].freeze

def handle_webhook(raw_body, headers)
  event = CalmLens.parse_webhook_event(
    payload: raw_body,
    signature: headers.fetch('x-calmlens-signature', ''),
    timestamp: headers.fetch('x-calmlens-timestamp', ''),
    secret: ENV.fetch('CALMLENS_WEBHOOK_SECRET')
  )
  asset = event.data['asset'] || {}
  external_id = asset['externalId']
  if external_id && STATUS_EVENTS.include?(event.type)
    # Store the new status and re-render from it. Skip events older than the one you stored.
    status = asset.dig('classification', 'moderation', 'status')
    update_moderation_status(external_id, status, event.created_at)
  elsif external_id && event.type == 'asset.failed'
    mark_moderation_failed(external_id)
  end
  204
rescue CalmLens::WebhookVerificationError
  401
end
```

**rust**

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

// 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();
    let event = match calmlens::construct_webhook_event(&body, &headers, &secret) {
        Ok(event) => event,
        Err(error) if error.code() == &calmlens::ErrorCode::InvalidWebhookSignature => return StatusCode::UNAUTHORIZED,
        Err(_) => return StatusCode::BAD_REQUEST,
    };

    let Some(asset) = event.data.asset else { return StatusCode::NO_CONTENT };
    let Some(external_id) = asset.external_id() else { return StatusCode::NO_CONTENT };
    match event.event_type {
        EventType::AssetAllowed | EventType::AssetLimited | EventType::AssetAgeRestricted | EventType::AssetBlocked | EventType::AssetPurged => {
            // Store the new status and re-render from it. Skip events older than the one you stored.
            let status = asset.moderation_status().unwrap_or_default();
            update_moderation_status(external_id, status.as_str(), event.created_at).await;
        }
        EventType::AssetFailed => mark_moderation_failed(external_id).await,
        _ => {}
    }
    StatusCode::NO_CONTENT
}
```

**java**

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

public class Example {
  static final Set<String> STATUS_EVENTS =
      Set.of("asset.allowed", "asset.limited", "asset.age-restricted", "asset.blocked", "asset.purged");

  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;
    }

    var asset = event.data().asset();
    if (asset == null || asset.externalId() == null) return 204;
    if (STATUS_EVENTS.contains(event.type())) {
      // Store the new status and re-render from it. Skip events older than the one you stored.
      String status = asset.classification().moderation().status().value();
      updateModerationStatus(asset.externalId(), status, event.createdAt());
    } else if (event.type().equals("asset.failed")) {
      markModerationFailed(asset.externalId());
    }
    return 204;
  }
}
```

The same events arrive when a reviewer changes the decision later, with the `decided-by` fact set to `reviewer`. Store the status from each one; [Status changes after publishing](/docs/guides/webhooks#status-changes-after-publishing) covers what changes for delivery. See [Webhooks](/docs/guides/webhooks) for setup, signatures and retries.

## 6. Render each status

Show the post according to the stored status. The uploader sees more than everyone else:

| Status           | Uploader                               | Everyone else                                                            |
| ---------------- | -------------------------------------- | ------------------------------------------------------------------------ |
| `pending`        | The photo, marked as being checked     | Nothing                                                                  |
| `allowed`        | The photo                              | The photo                                                                |
| `limited`        | The photo                              | The photo, left out of recommendations and notifications                 |
| `age-restricted` | The photo, marked as age-restricted    | The photo for viewers your app allows, the placeholder for everyone else |
| `blocked`        | An explanation that it can't be posted | Nothing                                                                  |
| `purged`         | An explanation that it was removed     | Nothing                                                                  |

While a photo is pending, the CDN may serve it unless [Block assets until moderation finishes](/portal/settings#block-unmoderated-assets) is on, so show the uploader a local preview of their file instead of a CDN URL. [Using the decision](/docs/guides/classifying-assets#using-the-decision) is the reference for each status, [Show sensitive media](/docs/guides/sensitive-media) builds the age-restricted case, and [Delivering assets](/docs/guides/delivering-assets) covers CDN URLs.

## 7. Test every outcome

Upload the [test input](/docs/guides/testing#test-real-moderation) images for your project to see the component and your webhook handle blocked and age-restricted photos without uploading real harmful content.
