# Moderate comments and posts

Check each comment on your server before anyone else can see it, give the author a useful message when it isn't posted, and keep your stored copy in step with later reviewer decisions. Text is classified in the request, so most comments are published or refused straight away.

## 1. Set up the server client

Create an API key in [API keys](/portal/api-keys) and keep it on your server with your project ID, as in the [quick start](/docs). Choose what gets blocked or age-restricted in [Policies and actions](/docs/guides/moderation-policies); the steps below work with any policy.

**JavaScript/TypeScript Server SDK**

```bash
npm install @calmlens/sdk
```

**python**

```bash
python -m pip install calmlens
```

**go**

```bash
go get github.com/CalmLens/sdks/go@latest
```

**ruby**

```bash
gem install calmlens
```

**rust**

```bash
cargo add calmlens serde_json tokio --features tokio/macros,tokio/rt-multi-thread,tokio/time
```

**java**

```xml
<dependency>
  <groupId>com.calmlens</groupId>
  <artifactId>calmlens-sdk</artifactId>
  <version>0.1.2</version>
</dependency>
```

**JavaScript/TypeScript Server SDK**

```typescript
import { CalmLensServerClient } from "@calmlens/sdk/server";

const client = new CalmLensServerClient({
  apiKey: (process.env.CALMLENS_API_KEY ?? ""),
  projectId: (process.env.CALMLENS_PROJECT_ID ?? ""),
});
```

**python**

```python
import os
from calmlens import CalmLensServerClient

client = CalmLensServerClient(
    api_key=os.environ["CALMLENS_API_KEY"],
    project_id=os.environ["CALMLENS_PROJECT_ID"],
)

print("SDK client ready")
```

**go**

```go
package main

import (
    "fmt"
    "log"
    "os"
    calmlens "github.com/CalmLens/sdks/go"
)

func main() {
    client, err := calmlens.NewServerClient(calmlens.Options{APIKey: os.Getenv("CALMLENS_API_KEY"), ProjectID: os.Getenv("CALMLENS_PROJECT_ID")})
    if err != nil { log.Fatal(err) }
    fmt.Println("SDK client ready", client != nil)
}

```

**ruby**

```ruby
require 'calmlens'
require 'json'

client = CalmLens::ServerClient.new(
  api_key: ENV.fetch('CALMLENS_API_KEY'),
  project_id: ENV.fetch('CALMLENS_PROJECT_ID')
)

puts "SDK client ready"

```

**rust**

```rust
use calmlens::ServerClient;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = ServerClient::new(std::env::var("CALMLENS_API_KEY")?, std::env::var("CALMLENS_PROJECT_ID")?)?;
    println!("SDK client ready");

    Ok(())
}

```

**java**

```java
import com.calmlens.CalmLensServerClient;

public class Example {
  public static void main(String[] args) throws Exception {
    var client = new CalmLensServerClient(System.getenv("CALMLENS_API_KEY"), System.getenv("CALMLENS_PROJECT_ID"));
    System.out.println("SDK client ready");

  }
}

```

## 2. Classify the comment before saving it

In the endpoint that receives a new comment, call `client.text.classify()` with the text and an `externalId` that identifies the comment. It waits for the decision. Save the comment with the returned asset ID and status whatever the outcome, then answer the browser:

**TypeScript Server SDK**

```typescript
async function createComment(comment: { id: string; body: string }) {
  const result = await client.text.classify({ text: comment.body, externalId: `comment-${comment.id}` });
  const { status, policy } = result.classification.moderation;

  // Keep every comment with its asset ID and status, so a reviewer's later decision can update it.
  await saveComment({ ...comment, assetId: result.id, status });

  if (status === "allowed" || status === "limited" || status === "age-restricted") {
    return Response.json({ id: comment.id, status }, { status: 201 });
  }
  return Response.json(
    { code: "comment/refused", categories: policy?.decision?.flaggedCategories ?? [] },
    { status: 422 },
  );
}
```

**JavaScript Server SDK**

```javascript
async function createComment(comment) {
  const result = await client.text.classify({ text: comment.body, externalId: `comment-${comment.id}` });
  const { status, policy } = result.classification.moderation;

  // Keep every comment with its asset ID and status, so a reviewer's later decision can update it.
  await saveComment({ ...comment, assetId: result.id, status });

  if (status === "allowed" || status === "limited" || status === "age-restricted") {
    return Response.json({ id: comment.id, status }, { status: 201 });
  }
  return Response.json(
    { code: "comment/refused", categories: policy?.decision?.flaggedCategories ?? [] },
    { status: 422 },
  );
}
```

**python**

```python
def create_comment(comment):
    result = client.text.classify(text=comment["body"], external_id=f"comment-{comment['id']}")
    moderation = result["classification"]["moderation"]

    # Keep every comment with its asset ID and status, so a reviewer's later decision can update it.
    save_comment(comment, asset_id=result["id"], status=moderation["status"])

    if moderation["status"] in ("allowed", "limited", "age-restricted"):
        return 201, {"id": comment["id"], "status": moderation["status"]}
    policy = moderation.get("policy")
    decision = policy.get("decision") if policy else None
    categories = decision["flaggedCategories"] if decision else []
    return 422, {"code": "comment/refused", "categories": categories}
```

**go**

```go
package main

import (
    "context"
    "net/http"

    calmlens "github.com/CalmLens/sdks/go"
)

func createComment(ctx context.Context, client *calmlens.ServerClient, comment Comment) (int, any, error) {
    externalID := "comment-" + comment.ID
    result, err := client.Text.Classify(ctx, calmlens.ClassifyTextOptions{Text: comment.Body, ExternalID: &externalID})
    if err != nil { return 0, nil, err }
    moderation := result.Classification.Moderation

    // Keep every comment with its asset ID and status, so a reviewer's later decision can update it.
    if err := saveComment(comment, result.ID, moderation.Status); err != nil { return 0, nil, err }

    if moderation.Status == calmlens.ModerationStatusAllowed || moderation.Status == calmlens.ModerationStatusLimited || moderation.Status == calmlens.ModerationStatusAgeRestricted {
        return http.StatusCreated, map[string]string{"id": comment.ID, "status": moderation.Status}, nil
    }
    categories := []string{}
    if moderation.Policy != nil && moderation.Policy.Decision != nil {
        categories = moderation.Policy.Decision.FlaggedCategories
    }
    return http.StatusUnprocessableEntity, map[string]any{"code": "comment/refused", "categories": categories}, nil
}
```

**ruby**

```ruby
def create_comment(client, comment)
  result = client.text.classify(text: comment[:body], external_id: "comment-#{comment[:id]}")
  moderation = result['classification']['moderation']

  # Keep every comment with its asset ID and status, so a reviewer's later decision can update it.
  save_comment(comment, asset_id: result['id'], status: moderation['status'])

  if %w[allowed limited age-restricted].include?(moderation['status'])
    [201, { id: comment[:id], status: moderation['status'] }]
  else
    categories = moderation.dig('policy', 'decision', 'flaggedCategories') || []
    [422, { code: 'comment/refused', categories: categories }]
  end
end
```

**rust**

```rust
use calmlens::{models::*, ServerClient};
use serde_json::{json, Value};

async fn create_comment(client: &ServerClient, comment: &Comment) -> Result<(u16, Value), Box<dyn std::error::Error>> {
    let result = client.text().classify(ClassifyText { text: comment.body.clone(), external_id: Some(format!("comment-{}", comment.id)), ..Default::default() }).await?;
    let moderation = &result.classification.moderation;

    // Keep every comment with its asset ID and status, so a reviewer's later decision can update it.
    save_comment(comment, &result.id, moderation.status.as_str()).await?;

    if moderation.status == AssetModerationStatus::Allowed || moderation.status == AssetModerationStatus::Limited || moderation.status == AssetModerationStatus::AgeRestricted {
        return Ok((201, json!({ "id": comment.id, "status": moderation.status.as_str() })));
    }
    let categories: Vec<&str> = moderation.policy.as_ref()
        .and_then(|policy| policy.decision.as_ref())
        .map(|decision| decision.flagged_categories.iter().map(|category| category.as_str()).collect())
        .unwrap_or_default();
    Ok((422, json!({ "code": "comment/refused", "categories": categories })))
}
```

**java**

```java
import com.calmlens.CalmLensServerClient;
import com.calmlens.Models.*;
import java.util.List;
import java.util.Map;

public class Example {
  record Reply(int status, Map<String, Object> body) {}

  static Reply createComment(CalmLensServerClient client, String commentId, String body) {
    Asset result = client.text.classify(new ClassifyText().text(body).externalId("comment-" + commentId));
    AssetModeration moderation = result.classification().moderation();

    // Keep every comment with its asset ID and status, so a reviewer's later decision can update it.
    saveComment(commentId, body, result.id(), moderation.status().value());

    if (moderation.status().equals(AssetModerationStatus.ALLOWED)
        || moderation.status().equals(AssetModerationStatus.LIMITED)
        || moderation.status().equals(AssetModerationStatus.AGE_RESTRICTED)) {
      return new Reply(201, Map.of("id", commentId, "status", moderation.status().value()));
    }
    List<String> categories = moderation.policy() != null && moderation.policy().decision() != null
        ? moderation.policy().decision().flaggedCategories()
        : List.of();
    return new Reply(422, Map.of("code", "comment/refused", "categories", categories));
  }
}
```

| Status                | Response                                                                                 |
| --------------------- | ---------------------------------------------------------------------------------------- |
| `allowed`             | Publish the comment.                                                                     |
| `limited`             | Publish the comment, but collapse it or sort it lower, and send no notifications for it. |
| `age-restricted`      | Publish it only to viewers your app allows to see age-restricted content.                |
| `blocked` or `purged` | Don't publish it. Tell the author why.                                                   |

Saving refused comments as well means a reviewer can still allow one later. For a purged comment, CalmLens has deleted its copy of the text; delete yours too if your policy requires it.

Every check is an upload, so it counts towards usage. Classify a comment when it is created and again when its text is edited. An `externalId` is unique within a project, so give each version its own, such as `comment-42-v2`; a repeat is refused with [`409 asset/duplicate-external-id`](/docs/api/assets#post-asset-error-asset-duplicate-external-id). [Classify assets](/docs/guides/classifying-assets) covers text limits and the other upload options.

## 3. Explain a refusal to the author

`classification.moderation.policy.decision.flaggedCategories` lists the categories that decided an automatic block or purge, such as `harassment` or `hate`. The example returns them with a stable `code`, and the form below turns them into a message and keeps the author's text so they can edit it:

**React**

```tsx
import { type FormEvent, useState } from "react";

const CATEGORY_NAMES: Record<string, string> = {
  harassment: "harassment",
  hate: "hate speech",
  sexual: "sexual content",
  violence: "violence",
  "self-harm": "self-harm",
  "criminal-activity": "criminal activity",
  profanity: "profanity",
  "personal-information": "personal information",
};

function refusalMessage({ categories = [] }: { categories?: string[] }) {
  const names = categories.map((category) => CATEGORY_NAMES[category]).filter(Boolean);
  return names.length
    ? `Your comment wasn't posted because it may contain ${names.join(", ")}. Edit it and try again.`
    : "Your comment wasn't posted because it may break our community guidelines.";
}

type CommentFormProps = {
  postId: string;
  onPosted: (comment: { id: string; status: string }) => void;
};

export function CommentForm({ postId, onPosted }: CommentFormProps) {
  const [sending, setSending] = useState(false);
  const [error, setError] = useState("");

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = event.currentTarget;
    setSending(true);
    setError("");
    const response = await fetch(`/api/posts/${postId}/comments`, {
      method: "POST",
      body: new FormData(form),
    });
    const result = await response.json();
    setSending(false);
    if (response.ok) {
      form.reset();
      onPosted(result);
    } else {
      setError(
        result.code === "comment/refused"
          ? refusalMessage(result)
          : "Your comment wasn't posted. Please try again.",
      );
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Comment <textarea name="body" required />
      </label>
      {error && <p role="alert">{error}</p>}
      <button type="submit" disabled={sending}>
        {sending ? "Posting…" : "Post"}
      </button>
    </form>
  );
}
```

**Vue**

```vue
<script setup lang="ts">
import { ref } from "vue";

const props = defineProps<{ postId: string }>();
const emit = defineEmits<{ posted: [comment: { id: string; status: string }] }>();

const CATEGORY_NAMES: Record<string, string> = {
  harassment: "harassment", hate: "hate speech", sexual: "sexual content", violence: "violence",
  "self-harm": "self-harm", "criminal-activity": "criminal activity", profanity: "profanity",
  "personal-information": "personal information",
};

function refusalMessage({ categories = [] }: { categories?: string[] }) {
  const names = categories.map((category) => CATEGORY_NAMES[category]).filter(Boolean);
  return names.length
    ? `Your comment wasn't posted because it may contain ${names.join(", ")}. Edit it and try again.`
    : "Your comment wasn't posted because it may break our community guidelines.";
}

const sending = ref(false);
const error = ref("");

async function handleSubmit(event: SubmitEvent) {
  const form = event.currentTarget as HTMLFormElement;
  sending.value = true;
  error.value = "";
  const response = await fetch(`/api/posts/${props.postId}/comments`, { method: "POST", body: new FormData(form) });
  const result = await response.json();
  sending.value = false;
  if (response.ok) {
    form.reset();
    emit("posted", result);
  } else {
    error.value = result.code === "comment/refused" ? refusalMessage(result) : "Your comment wasn't posted. Please try again.";
  }
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <label>Comment <textarea name="body" required /></label>
    <p v-if="error" role="alert">{{ error }}</p>
    <button type="submit" :disabled="sending">{{ sending ? "Posting…" : "Post" }}</button>
  </form>
</template>
```

**Svelte**

```svelte
<script lang="ts">
  type Props = { postId: string; onPosted: (comment: { id: string; status: string }) => void };
  let { postId, onPosted }: Props = $props();
  let sending = $state(false);
  let error = $state("");

  const CATEGORY_NAMES: Record<string, string> = {
    harassment: "harassment", hate: "hate speech", sexual: "sexual content", violence: "violence",
    "self-harm": "self-harm", "criminal-activity": "criminal activity", profanity: "profanity",
    "personal-information": "personal information",
  };

  function refusalMessage({ categories = [] }: { categories?: string[] }) {
    const names = categories.map((category) => CATEGORY_NAMES[category]).filter(Boolean);
    return names.length
      ? `Your comment wasn't posted because it may contain ${names.join(", ")}. Edit it and try again.`
      : "Your comment wasn't posted because it may break our community guidelines.";
  }

  async function handleSubmit(event: SubmitEvent) {
    event.preventDefault();
    const form = event.currentTarget as HTMLFormElement;
    sending = true;
    error = "";
    const response = await fetch(`/api/posts/${postId}/comments`, { method: "POST", body: new FormData(form) });
    const result = await response.json();
    sending = false;
    if (response.ok) {
      form.reset();
      onPosted(result);
    } else {
      error = result.code === "comment/refused" ? refusalMessage(result) : "Your comment wasn't posted. Please try again.";
    }
  }
</script>

<form onsubmit={handleSubmit}>
  <label>Comment <textarea name="body" required></textarea></label>
  {#if error}<p role="alert">{error}</p>{/if}
  <button type="submit" disabled={sending}>{sending ? "Posting…" : "Post"}</button>
</form>
```

**Plain JavaScript**

```javascript
const CATEGORY_NAMES = {
  harassment: "harassment",
  hate: "hate speech",
  sexual: "sexual content",
  violence: "violence",
  "self-harm": "self-harm",
  "criminal-activity": "criminal activity",
  profanity: "profanity",
  "personal-information": "personal information",
};

function refusalMessage({ categories = [] }) {
  const names = categories.map((category) => CATEGORY_NAMES[category]).filter(Boolean);
  return names.length
    ? `Your comment wasn't posted because it may contain ${names.join(", ")}. Edit it and try again.`
    : "Your comment wasn't posted because it may break our community guidelines.";
}

export function attachCommentForm(form, { postId, onPosted }) {
  const button = form.querySelector("button[type=submit]");
  const error = document.createElement("p");
  error.setAttribute("role", "alert");
  error.hidden = true;
  button.before(error);

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    button.disabled = true;
    button.textContent = "Posting…";
    error.hidden = true;
    const response = await fetch(`/api/posts/${postId}/comments`, {
      method: "POST",
      body: new FormData(form),
    });
    const result = await response.json();
    button.disabled = false;
    button.textContent = "Post";
    if (response.ok) {
      form.reset();
      onPosted(result);
    } else {
      error.textContent =
        result.code === "comment/refused"
          ? refusalMessage(result)
          : "Your comment wasn't posted. Please try again.";
      error.hidden = false;
    }
  });
}
```

Name the categories in plain words and avoid quoting scores or thresholds back to the author. The [category definitions](/docs/guides/moderation-categories) describe what each one covers, and [Reading classification results](/docs/guides/classification-results) describes the rest of the report.

## 4. Handle reviewer changes

A reviewer can allow, limit, age-restrict, block or purge a comment after your endpoint answered. 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) and [`asset.purged`](/docs/api/events#event-asset-purged), and update the comment that matches the event's `externalId`:

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

Re-render from the stored status, so an allowed comment appears and a blocked one disappears without further changes. Reviewer decisions carry the `decided-by` fact set to `reviewer`; [Status changes after publishing](/docs/guides/webhooks#status-changes-after-publishing) explains when they arrive. If you tell authors about decisions, send the message from this handler.

## 5. Render comments by status

Show each comment according to its stored status, using the rules in [Using the decision](/docs/guides/classifying-assets#using-the-decision): the author sees their refused comments with an explanation, and everyone else sees only allowed comments and the age-restricted ones your app lets them see.

## 6. Test with test inputs

Post the [test input](/docs/guides/testing#test-real-moderation) text codes for your project as comments to check refusals, messages and reviewer changes without writing harmful text. For unit tests, the SDK mock clients return any status without a network request; see [Unit tests without the network](/docs/guides/testing#unit-tests-without-the-network).
