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 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 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.
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 and stops starting uploads after 10 minutes by default; see 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:
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>
);
}
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, passuploadFetch: createProgressFetch(onProgress)from@calmlens/sdk/uitoCalmLensAppClient.client.assets.retrieve({ assetId })returnsstatus, 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 and 415 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.
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 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 Portal › Webhooks & connectors, subscribe your endpoint to asset.allowed, asset.limited, asset.age-restricted, asset.blocked, asset.purged and asset.failed. Each event carries the asset's externalId and current status, so the handler can update the post without another request:
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 });
}
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 covers what changes for delivery. See 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 is on, so show the uploader a local preview of their file instead of a CDN URL. Using the decision is the reference for each status, Show sensitive media builds the age-restricted case, and Delivering assets covers CDN URLs.
7. Test every outcome
Upload the test input images for your project to see the component and your webhook handle blocked and age-restricted photos without uploading real harmful content.