Show sensitive media
Keep age-restricted images in your feed without showing them to everyone: viewers your app allows see the image, and everyone else sees a blurred placeholder with a notice. CalmLens refuses age-restricted content on plain public URLs, so you choose how allowed viewers get it.
1. Age-restrict instead of blocking
An asset becomes age-restricted when your policy's Age-restrict action applies or a reviewer chooses it. Choose which categories age-restrict in Policies and actions. Upload the images with Keep content after moderation on and public visibility, for example through the preset in Build an upload component.
2. Store each image's status
Your feed needs the current classification.moderation.status of every image. Store it when the upload is saved and update it from asset.allowed, asset.limited, asset.age-restricted, asset.blocked and asset.purged webhooks:
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 });
}
A reviewer can age-restrict an image that was already allowed, and the CDN stops serving its plain URL within seconds. Updating the stored status re-renders it behind the placeholder; see Status changes after publishing.
3. Decide who may see it
CalmLens does not know your viewers or check anyone's age. Keep your own record of whether a viewer may see sensitive media, for example a setting a signed-in adult turns on, backed by the age assurance your service and jurisdiction require. Check age natively shows how to ask iOS, Android and browsers.
4. Opt in on the URL for allowed viewers
For viewers your app allows, add allowAgeRestricted=true to the image's CDN URL:
https://cdn.calmlens.com/v1/assets/{projectId}/{assetId}/rendered?allowAgeRestricted=trueThe SDKs' CDN URL builders accept an allowAgeRestricted option that adds it. The opt-in doesn't open private assets. CalmLens doesn't check the viewer on these URLs, so render them only where your app has allowed it; an opted-in URL works for anyone who has it.
If CalmLens itself must refuse everyone your backend hasn't approved, your server can issue short-lived signed URLs instead; see Age-restricted assets.
5. Show the placeholder to everyone else
Every public asset with a preview has a /placeholder image, a small blurred PNG, served whatever its moderation status:
https://cdn.calmlens.com/v1/assets/{projectId}/{assetId}/placeholderScale it to the image's size with CSS. It isn't served for purged assets or assets without a preview; see Show the placeholder to everyone else.
6. Build the sensitive image component
This component shows the image when the asset is allowed or the viewer may see it, adding the allowAgeRestricted opt-in for age-restricted images, and otherwise the placeholder with a notice and a View button.
import { getAssetUrl } from "@calmlens/sdk/app";
type SensitiveImageProps = {
projectId: string;
assetId: string;
status: "allowed" | "limited" | "age-restricted";
canView: boolean;
alt: string;
/** Width divided by height, which you store when the image is uploaded. */
aspectRatio: number;
onReveal: () => void;
};
const fill = { width: "100%", height: "100%", objectFit: "cover" } as const;
export function SensitiveImage({
projectId,
assetId,
status,
canView,
alt,
aspectRatio,
onReveal,
}: SensitiveImageProps) {
const restricted = status === "age-restricted";
const asset = { projectId, assetIdentifier: assetId };
return (
<figure style={{ aspectRatio, margin: 0, position: "relative" }}>
{!restricted || canView ? (
<img
src={getAssetUrl({ ...asset, allowAgeRestricted: restricted })}
alt={alt}
style={fill}
/>
) : (
<>
<img src={getAssetUrl({ ...asset, resource: "placeholder" })} alt="" style={fill} />
<figcaption
style={{ position: "absolute", inset: 0, display: "grid", placeContent: "center" }}
>
Sensitive content{" "}
<button type="button" onClick={onReveal}>
View
</button>
</figcaption>
</>
)}
</figure>
);
}
onReveal runs your app's own check, such as asking the viewer to confirm their age or sign in, and re-renders with canView once it passes. The component is explained in A sensitive-media component.
The SDK's media component does the same with status, canView and onReveal, and adds responsive sources and a placeholder while the image loads.
7. Render the feed
Show only allowed and age-restricted images in the feed, each through the sensitive image component:
import type { AssetModerationStatus } from "@calmlens/sdk/app";
import { SensitiveImage } from "./SensitiveImage";
type Post = {
id: string;
assetId: string;
status: AssetModerationStatus;
alt: string;
aspectRatio: number;
};
type ShownPost = Post & { status: "allowed" | "limited" | "age-restricted" };
const isShown = (post: Post): post is ShownPost =>
post.status === "allowed" || post.status === "limited" || post.status === "age-restricted";
type FeedProps = {
projectId: string;
posts: Post[];
viewer: { canViewSensitiveMedia: boolean };
onReveal: () => void;
};
export function Feed({ projectId, posts, viewer, onReveal }: FeedProps) {
return (
<ul className="feed">
{posts.filter(isShown).map((post) => (
<li key={post.id}>
<SensitiveImage
projectId={projectId}
assetId={post.assetId}
status={post.status}
canView={viewer.canViewSensitiveMedia}
onReveal={onReveal}
alt={post.alt}
aspectRatio={post.aspectRatio}
/>
</li>
))}
</ul>
);
}
Filter by status on your server as well, so blocked, purged and pending images never reach other viewers' feeds; the filter in the component is a second guard. The author of a post can still see their own pending or blocked uploads, as described in Using the decision.
8. Test it
Upload a test input image for a category your policy age-restricts, then load the feed as a viewer with and without your setting turned on.