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 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 explains each setting.

2. Create an avatar lens

A lens is a named CDN transform. In 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 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:

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.

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

Return { token } from the endpoint. Each token uploads one file; see Upload tokens.

4. Upload from the profile page

Use the upload component from Build an 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:

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

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 Portal › Webhooks & connectors to asset.allowed, asset.limited, asset.age-restricted, asset.blocked, asset.purged and asset.failed, and match each event's externalId to the user:

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

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.

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.

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)} />;
}
StatusThe userEveryone else
pendingTheir previous picture, and a local preview marked as being checkedTheir previous picture or the fallback
allowed or limitedThe pictureThe picture
age-restrictedA message asking for a different pictureThe fallback
blocked or purgedA message that the picture can't be usedThe 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. Using the decision is the reference for each status.

7. Test it

Upload the test input images for your project as profile pictures to check the fallback, the uploader's messages and your webhook without real harmful content.

Signing in

Signing out

Cookies on CalmLens

We use essential cookies for sign-in and security. With your permission, functional cookies remember your preferences and Google Analytics measures website use and signups. Optional ad measurement cookies link Google ad clicks to signups. Personalised advertising stays off. Manage cookies