# Moderate profile pictures

Let users upload a profile picture from your app, show it everywhere as a small square thumbnail once it's allowed, and show a fallback avatar while it's checked or if it's blocked. Profile pictures appear next to everything a user does, so only an allowed picture is shown to other people.

## 1. Create an upload preset for avatars

In [Upload presets](/portal/upload-presets), create a preset for profile pictures that accepts uploads with a token from your server, accepts `image/*` up to the size you allow, uses public visibility, keeps content after moderation and shows the decision to the uploader. A separate preset keeps avatars in their own folder with their own limits. [Upload presets](/docs/guides/upload-presets) explains each setting.

## 2. Create an avatar lens

A lens is a named CDN transform. In [Lenses](/portal/lenses), create one with the identifier `avatar` and an `imageTransform` with `fit=cover`, `width=256` and `height=256`, or create it from code with `client.ensureLens()`. Each picture is resized once and the result is reused; see [Lenses and transforms](/docs/guides/lenses) for the options and billing.

Request the lens on the rendered resource, which serves each browser the best format it accepts and also converts HEIC photos from phones:

```text
https://cdn.calmlens.com/v1/assets/{projectId}/{assetId}/rendered/@avatar
```

## 3. Sign a token for each new picture

Add an endpoint that checks the signed-in user and signs an upload token with `client.uploadTokens.sign({ uploadPresetId, externalId, expiresInSeconds })`. Set `externalId` to a new ID for each upload that names the user, such as `avatar-<user ID>-<upload ID>`. An `externalId` can belong to only one asset in a project, so a user's second picture needs a new one.

**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; see [Upload tokens](/docs/guides/upload-presets#upload-tokens).

## 4. Upload from the profile page

Use the upload component from [Build an upload component](/docs/guides/upload-component), pointed at the avatar preset and your avatar token endpoint. It uploads the file, shows the uploader when their picture is checked, allowed or blocked, and returns the asset ID:

**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");
    }
  });
}
```

Send the asset ID to your server. Retrieve the asset with `client.assets.retrieve({ assetId })`, check that its `externalId` is one you signed for this user, and store the asset ID and status as the user's new picture. Keep the previous picture until the new one is allowed, so the user isn't left without one while it's checked.

## 5. Update the picture from webhooks

Subscribe a webhook in [Webhooks & connectors](/portal/connectors) 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), and match each event's `externalId` to the user:

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

When the new picture is allowed, make it the user's picture and delete the old asset if you no longer need it. If a reviewer later blocks or purges the picture, the stored status changes and the fallback appears; see [Status changes after publishing](/docs/guides/webhooks#status-changes-after-publishing).

## 6. Render the avatar with a fallback

Show the lens image only when the stored status is `allowed`, and the user's initial otherwise. The image also falls back if the CDN refuses it, for example after a status change your page hasn't loaded yet.

**React**

```tsx
import { getAssetUrl } from "@calmlens/sdk/app";
import { useState } from "react";

type AvatarProps = { projectId: string; assetId?: string; status?: string; name: string };

export function Avatar({ projectId, assetId, status, name }: AvatarProps) {
  const [failed, setFailed] = useState(false);
  if (!assetId || (status !== "allowed" && status !== "limited") || failed) {
    return (
      <span className="avatar" role="img" aria-label={name}>
        {name.charAt(0).toUpperCase()}
      </span>
    );
  }
  const src = getAssetUrl({
    projectId,
    assetIdentifier: assetId,
    resource: "rendered",
    lens: "avatar",
  });
  return <img className="avatar" src={src} alt={name} onError={() => setFailed(true)} />;
}
```

**Vue**

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

const props = defineProps<{ projectId: string; assetId?: string; status?: string; name: string }>();
const failed = ref(false);
const src = computed(() =>
  props.assetId
    ? getAssetUrl({ projectId: props.projectId, assetIdentifier: props.assetId, resource: "rendered", lens: "avatar" })
    : undefined,
);
</script>

<template>
  <img v-if="src && (status === 'allowed' || status === 'limited') && !failed" class="avatar" :src="src" :alt="name" @error="failed = true" />
  <span v-else class="avatar" role="img" :aria-label="name">{{ name.charAt(0).toUpperCase() }}</span>
</template>
```

**Svelte**

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

  let { projectId, assetId, status, name }: { projectId: string; assetId?: string; status?: string; name: string } = $props();
  let failed = $state(false);
  const src = $derived(
    assetId ? getAssetUrl({ projectId, assetIdentifier: assetId, resource: "rendered", lens: "avatar" }) : undefined,
  );
</script>

{#if src && (status === "allowed" || status === "limited") && !failed}
  <img class="avatar" {src} alt={name} onerror={() => (failed = true)} />
{:else}
  <span class="avatar" role="img" aria-label={name}>{name.charAt(0).toUpperCase()}</span>
{/if}
```

**Plain JavaScript**

```javascript
import { getAssetUrl } from "@calmlens/sdk/app";

export function renderAvatar(container, { projectId, assetId, status, name }) {
  const fallback = () => {
    const initial = document.createElement("span");
    initial.className = "avatar";
    initial.setAttribute("role", "img");
    initial.setAttribute("aria-label", name);
    initial.textContent = name.charAt(0).toUpperCase();
    container.replaceChildren(initial);
  };
  if (!assetId || (status !== "allowed" && status !== "limited")) return fallback();

  const img = document.createElement("img");
  img.className = "avatar";
  img.src = getAssetUrl({
    projectId,
    assetIdentifier: assetId,
    resource: "rendered",
    lens: "avatar",
  });
  img.alt = name;
  img.addEventListener("error", fallback);
  container.replaceChildren(img);
}
```

| Status                 | The user                                                            | Everyone else                          |
| ---------------------- | ------------------------------------------------------------------- | -------------------------------------- |
| `pending`              | Their previous picture, and a local preview marked as being checked | Their previous picture or the fallback |
| `allowed` or `limited` | The picture                                                         | The picture                            |
| `age-restricted`       | A message asking for a different picture                            | The fallback                           |
| `blocked` or `purged`  | A message that the picture can't be used                            | The fallback                           |

Profile pictures appear in places where a reveal control doesn't fit, so treat age-restricted pictures as not shown. For images that should stay visible to some viewers, see [Show sensitive media](/docs/guides/sensitive-media). [Using the decision](/docs/guides/classifying-assets#using-the-decision) is the reference for each status.

## 7. Test it

Upload the [test input](/docs/guides/testing#test-real-moderation) images for your project as profile pictures to check the fallback, the uploader's messages and your webhook without real harmful content.
