# 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](/docs/guides/moderation-policies). Upload the images with **Keep content after moderation** on and public visibility, for example through the preset in [Build an upload component](/docs/guides/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`](/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) and [`asset.purged`](/docs/api/events#event-asset-purged) webhooks:

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

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](/docs/guides/webhooks#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](/docs/guides/native-age-checks) 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:

```text
https://cdn.calmlens.com/v1/assets/{projectId}/{assetId}/rendered?allowAgeRestricted=true
```

The 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](/docs/guides/delivering-assets#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:

```text
https://cdn.calmlens.com/v1/assets/{projectId}/{assetId}/placeholder
```

Scale 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](/docs/guides/delivering-assets#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.

**React**

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

**Vue**

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

const props = defineProps<{
  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;
}>();
const emit = defineEmits<{ reveal: [] }>();

const restricted = computed(() => props.status === "age-restricted");
const asset = computed(() => ({ projectId: props.projectId, assetIdentifier: props.assetId }));
const contentUrl = computed(() => getAssetUrl({ ...asset.value, allowAgeRestricted: restricted.value }));
const placeholderUrl = computed(() => getAssetUrl({ ...asset.value, resource: "placeholder" }));
</script>

<template>
  <figure class="sensitive" :style="{ aspectRatio }">
    <img v-if="!restricted || canView" :src="contentUrl" :alt="alt" />
    <template v-else>
      <img :src="placeholderUrl" alt="" />
      <figcaption>Sensitive content <button type="button" @click="emit('reveal')">View</button></figcaption>
    </template>
  </figure>
</template>

<style scoped>
.sensitive { margin: 0; position: relative; }
img { width: 100%; height: 100%; object-fit: cover; }
figcaption { position: absolute; inset: 0; display: grid; place-content: center; }
</style>
```

**Svelte**

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

  type Props = {
    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;
  };
  let { projectId, assetId, status, canView, alt, aspectRatio, onReveal }: Props = $props();

  const restricted = $derived(status === "age-restricted");
  const asset = $derived({ projectId, assetIdentifier: assetId });
</script>

<figure class="sensitive" style:aspect-ratio={aspectRatio}>
  {#if !restricted || canView}
    <img src={getAssetUrl({ ...asset, allowAgeRestricted: restricted })} {alt} />
  {:else}
    <img src={getAssetUrl({ ...asset, resource: "placeholder" })} alt="" />
    <figcaption>Sensitive content <button type="button" onclick={onReveal}>View</button></figcaption>
  {/if}
</figure>

<style>
  .sensitive { margin: 0; position: relative; }
  img { width: 100%; height: 100%; object-fit: cover; }
  figcaption { position: absolute; inset: 0; display: grid; place-content: center; }
</style>
```

**Plain JavaScript**

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

// aspectRatio is width divided by height, which you store when the image is uploaded.
export function renderSensitiveImage(
  container,
  { projectId, assetId, status, canView, alt, aspectRatio, onReveal },
) {
  const restricted = status === "age-restricted";
  const asset = { projectId, assetIdentifier: assetId };
  const figure = document.createElement("figure");
  figure.style.cssText = `aspect-ratio: ${aspectRatio}; margin: 0; position: relative`;
  const img = document.createElement("img");
  img.style.cssText = "width: 100%; height: 100%; object-fit: cover";
  figure.append(img);

  if (!restricted || canView) {
    img.src = getAssetUrl({ ...asset, allowAgeRestricted: restricted });
    img.alt = alt;
  } else {
    img.src = getAssetUrl({ ...asset, resource: "placeholder" });
    img.alt = "";
    const button = Object.assign(document.createElement("button"), {
      type: "button",
      textContent: "View",
    });
    button.addEventListener("click", onReveal);
    const caption = document.createElement("figcaption");
    caption.style.cssText = "position: absolute; inset: 0; display: grid; place-content: center";
    caption.append("Sensitive content ", button);
    figure.append(caption);
  }
  container.replaceChildren(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](/docs/guides/delivering-assets#a-sensitive-media-component).

The SDK's [media component](/docs/guides/upload-widget#show-media) 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:

**React**

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

**Vue**

```vue
<script setup lang="ts">
import type { AssetModerationStatus } from "@calmlens/sdk/app";
import { computed } from "vue";
import SensitiveImage from "./SensitiveImage.vue";

type Post = { id: string; assetId: string; status: AssetModerationStatus; alt: string; aspectRatio: number };
type ShownPost = Post & { status: "allowed" | "limited" | "age-restricted" };
const props = defineProps<{ projectId: string; posts: Post[]; canViewSensitiveMedia: boolean }>();
const emit = defineEmits<{ reveal: [] }>();
const shown = computed(() =>
  props.posts.filter((post): post is ShownPost => post.status === "allowed" || post.status === "limited" || post.status === "age-restricted"),
);
</script>

<template>
  <ul class="feed">
    <li v-for="post in shown" :key="post.id">
      <SensitiveImage
        :project-id="projectId"
        :asset-id="post.assetId"
        :status="post.status"
        :can-view="canViewSensitiveMedia"
        :alt="post.alt"
        :aspect-ratio="post.aspectRatio"
        @reveal="emit('reveal')"
      />
    </li>
  </ul>
</template>
```

**Svelte**

```svelte
<script lang="ts">
  import type { AssetModerationStatus } from "@calmlens/sdk/app";
  import SensitiveImage from "./SensitiveImage.svelte";

  type Post = { id: string; assetId: string; status: AssetModerationStatus; alt: string; aspectRatio: number };
  type ShownPost = Post & { status: "allowed" | "limited" | "age-restricted" };
  type Props = {
    projectId: string;
    posts: Post[];
    viewer: { canViewSensitiveMedia: boolean };
    onReveal: () => void;
  };
  let { projectId, posts, viewer, onReveal }: Props = $props();
  const shown = $derived(
    posts.filter((post): post is ShownPost => post.status === "allowed" || post.status === "limited" || post.status === "age-restricted"),
  );
</script>

<ul class="feed">
  {#each shown as post (post.id)}
    <li>
      <SensitiveImage
        {projectId}
        assetId={post.assetId}
        status={post.status}
        canView={viewer.canViewSensitiveMedia}
        alt={post.alt}
        aspectRatio={post.aspectRatio}
        {onReveal}
      />
    </li>
  {/each}
</ul>
```

**Plain JavaScript**

```javascript
import { renderSensitiveImage } from "./sensitive-image.js";

export function renderFeed(list, { projectId, posts, viewer, onReveal }) {
  const items = posts
    .filter((post) => ["allowed", "limited", "age-restricted"].includes(post.status))
    .map((post) => {
      const item = document.createElement("li");
      renderSensitiveImage(item, {
        projectId,
        assetId: post.assetId,
        status: post.status,
        canView: viewer.canViewSensitiveMedia,
        alt: post.alt,
        aspectRatio: post.aspectRatio,
        onReveal,
      });
      return item;
    });
  list.replaceChildren(...items);
}
```

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](/docs/guides/classifying-assets#using-the-decision).

## 8. Test it

Upload a [test input](/docs/guides/testing#test-real-moderation) image for a category your policy age-restricts, then load the feed as a viewer with and without your setting turned on.
