# Webhooks

Receive moderation decisions as signed HTTPS requests. Create a webhook connector, verify the raw payload, then acknowledge it before doing slower work. For Slack, email and portal notifications, see [Notifications and connectors](/docs/guides/notifications).

## Receive a queued moderation result

Use `client.assets.create()` (`queue=true` over HTTP) to queue moderation and let your app continue while it runs. Save the returned asset ID and subscribe to:

- [`asset.classified`](/docs/api/events#event-asset-classified) for the completed moderation decision. Read `data.asset.status` and match `data.assetId` to your submission.
- [`asset.failed`](/docs/api/events#event-asset-failed) for a processing failure. Keep the content unpublished and handle the failure.

If you need the decision in the original request, use `client.assets.moderate()` (`queue=false`) instead. Text and images support either flow; audio and video always run asynchronously. See [Uploading assets](/docs/guides/uploading-assets#waiting-for-the-decision).

## Create a connector

1. Open [Webhooks & connectors](/portal/connectors) and add a connector.
2. Choose a type and give it a name. The steps below are for a **webhook**; see [Slack](#slack) and [Email](#email) for the other types.
3. Enter the endpoint URL and a signing secret of at least 10 characters. Store the same secret in your receiving app, for example as `CALMLENS_WEBHOOK_SECRET`. The portal never shows the secret again, only a hint of its last characters.
4. Select the event types to subscribe to. An empty selection subscribes the connector to all events.

Use the final HTTPS endpoint URL. Private IP addresses, localhost names and URL credentials are rejected. Redirect responses count as failed attempts; CalmLens never forwards a signed payload to a redirected destination. To receive events on your own machine, use [`calmlens listen`](#local-development) instead.

You can disable a connector without deleting it. Deliveries still waiting for a retry fail once their connector is disabled or deleted.

## Webhook payload

A webhook connector sends a `POST` request with a JSON body:

```json
{
  "id": "event-uuid",
  "type": "asset.classified",
  "createdAt": 1760000000000,
  "projectId": "project-uuid",
  "data": {
    "severity": "low",
    "title": "Webhooks",
    "description": "Automated review found potential policy violations.",
    "assetId": "asset-uuid",
    "asset": {
      "id": "asset-uuid",
      "externalId": "post-123",
      "status": "rejected",
      "flagged": true,
      "moderationAction": "hide"
    },
    "facts": [
      { "key": "asset-name", "value": "beach.jpg", "url": "/portal/assets/asset-uuid" },
      { "key": "asset-type", "value": "image" },
      { "key": "status", "value": "rejected" },
      { "key": "moderation-action", "value": "hide" }
    ]
  }
}
```

| Field | Meaning |
| --- | --- |
| `id` | Event ID. The same event sent to several connectors has the same `id`. |
| `type` | Event type, such as [`asset.classified`](/docs/api/events#event-asset-classified). |
| `createdAt` | When the event happened, in milliseconds since the Unix epoch. |
| `projectId` | Project the event belongs to. |
| `data.severity` | `low` or `high`. |
| `data.title`, `data.description` | Human-readable summary; `description` may be `null`. |
| `data.assetId` | Asset the event is about, or `null`. |
| `data.asset` | The asset's moderation decision when the event was created, or `null`. |
| `data.facts` | Details for the event's type (see [Event facts](#event-facts)), as an array. |

The `data.asset` snapshot means most receivers don't need to call the API:

| Field | Meaning |
| --- | --- |
| `id` | Asset ID. |
| `externalId` | Your external identifier, or `null`. |
| `status` | `uploading`, `pending`, `approved`, `rejected` or `error`. |
| `flagged` | Whether moderation flagged the asset; `null` before classification. |
| `moderationAction` | Applied action (`ignore`, `alert`, `hide` or `purge`), or `null`. |

Additive fields may appear in future, so ignore properties your handler does not use.

### Headers

| Header | Value |
| --- | --- |
| `content-type` | `application/json` |
| `x-calmlens-delivery` | Delivery ID, the same on every attempt of a delivery. |
| `x-calmlens-timestamp` | When this attempt was signed, in Unix seconds. |
| `x-calmlens-signature` | `v1=` followed by the lowercase hex HMAC-SHA256 of `<timestamp>.<raw body>`, keyed with the connector's signing secret. |

## Verify signatures

Verify every request before trusting it. Pass the exact raw request body, not JSON you have parsed and re-serialized, along with the signature and timestamp headers. The SDK helpers check the signature in constant time and reject timestamps more than 5 minutes from your server's clock, which limits the replay window. Deduplicate events by ID as described below.

**JavaScript**

```javascript
import { CalmLensError, parseWebhookEvent } from "@calmlens/sdk";

export async function POST(request: Request) {
  let event;
  try {
    // Verifies the signature over the raw body, then parses it.
    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("Invalid delivery", { status: 401 });
    throw error;
  }
  // Queue the work, keyed by event.id, then respond quickly.
  return new Response(null, { status: 204 });
}
```

**python**

```python
import os

from calmlens import WebhookVerificationError, parse_webhook_event

def handle_webhook(raw_body: bytes, headers) -> int:
    try:
        # Verifies the signature of the exact raw body, then parses it.
        event = parse_webhook_event(
            raw_body,
            headers=headers,
            secret=os.environ["CALMLENS_WEBHOOK_SECRET"],
        )
    except WebhookVerificationError:
        return 401
    # Queue the work, keyed by event["id"], then respond quickly.
    return 204
```

**go**

```go
package main

import (
    "log"
    "net/http"
    "os"

    calmlens "github.com/Censkh/CalmLens/packages/go-sdk"
)

func handleWebhook(w http.ResponseWriter, r *http.Request) {
	// Reads the raw body (up to 1 MiB), verifies it, then decodes the event.
	event, err := calmlens.VerifyWebhookRequest(r, os.Getenv("CALMLENS_WEBHOOK_SECRET"))
	if err != nil {
		http.Error(w, "Invalid webhook", http.StatusUnauthorized)
		return
	}
	// Queue the work, keyed by event.ID, then respond quickly.
	log.Println(event.Type, event.ID)
	w.WriteHeader(http.StatusNoContent)
}

func main() {
    http.HandleFunc("/webhooks", handleWebhook)
    if err := http.ListenAndServe(":8080", nil); err != nil { panic(err) }
}

```

**ruby**

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

def handle_webhook(raw_body, headers)
  valid = CalmLens.verify_webhook_signature(
    payload: raw_body,
    signature: headers.fetch('x-calmlens-signature', ''),
    timestamp: headers.fetch('x-calmlens-timestamp', ''),
    secret: ENV.fetch('CALMLENS_WEBHOOK_SECRET')
  )
  return 401 unless valid
  event = JSON.parse(raw_body)
  # Queue work keyed by event['id'], then respond quickly.
  204
end

```

**rust**

```rust
use axum::{body::Bytes, http::{HeaderMap, StatusCode}};

// 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();
    match calmlens::construct_webhook_event(&body, &headers, &secret) {
        Ok(event) => {
            // Queue work keyed by event.id, then respond quickly.
            println!("{} {:?}", event.event_type, event.data.asset_id);
            StatusCode::NO_CONTENT
        }
        Err(error) if error.code() == &calmlens::ErrorCode::InvalidWebhookSignature => StatusCode::UNAUTHORIZED,
        Err(_) => StatusCode::BAD_REQUEST,
    }
}

```

**java**

```java
import com.calmlens.CalmLensClient;
import com.calmlens.CalmLensException;
import com.calmlens.ErrorCodes;
import com.calmlens.Models.WebhookEventPayload;
import java.util.Map;

public class Example {
  static int handleWebhook(byte[] rawBody, Map<String, String> headers) {
    WebhookEventPayload event;
    try {
      event = CalmLensClient.parseWebhookEvent(
          rawBody,
          headers.get(CalmLensClient.WEBHOOK_SIGNATURE_HEADER),
          headers.get(CalmLensClient.WEBHOOK_TIMESTAMP_HEADER),
          System.getenv("CALMLENS_WEBHOOK_SECRET"));
    } catch (CalmLensException invalid) {
      return invalid.getCode().equals(ErrorCodes.SDK_INVALID_WEBHOOK_SIGNATURE) ? 401 : 400;
    }
    // Queue work keyed by event.id(), then respond quickly.
    System.out.println(event.type() + " " + event.data().assetId());
    return 204;
  }
}

```

## Test a connector

Use **Test** on a connector to send a synthetic event to it straight away. The test is a real delivery: a signed request to your webhook endpoint (checking the URL, your signature verification and your response), a message in your Slack channel, or an email to each recipient. It appears in the deliveries list marked as a test, and is not retried. Test events use example data for the type you choose, with `{ "key": "test", "value": true }` added to `facts`.

## Deliveries and retries

Every attempt to send an event to a connector is recorded as a delivery. The deliveries list in [Webhooks & connectors](/portal/connectors) shows each delivery's event, status, attempt count, the last response or error, and the next retry time.

Respond with a 2xx status within 10 seconds and move slow work to your own queue.

A failed delivery is retried up to 7 attempts in total, waiting about 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours between attempts (roughly 20.6 hours overall). Network errors, timeouts, `408`, `425`, `429` and `5xx` responses are retried. Other responses, including `3xx` and other `4xx` codes, fail the delivery immediately. A `Retry-After` header on a `429` or `503` response can lengthen the next wait, up to 12 hours.

Delivery is at least once, so a receiver can occasionally see the same event twice. Make handlers idempotent by storing the event `id`: every attempt of a delivery has the same delivery ID and event `id`, and a redelivery has a new delivery ID but the same event `id`.

### Redeliver

Use **Redeliver** on a delivery in the deliveries list to send its event to the same connector again, for example after fixing your endpoint. This creates a new delivery with the usual retries; the original stays in the list. The connector must be turned on, the event must still be stored (see [Retention](#retention)), and tests can't be redelivered.

### Automatic disabling

If 10 deliveries in a row to a connector fail for good, CalmLens turns the connector off and shows why next to its switch. A delivery fails for good when it runs out of retries or gets a response that isn't retried, so one outage that a delivery retries 7 times counts once, and any successful delivery starts the count again. When a connector is turned off this way, the project gets a **Connector failing** event (sent to your other connectors and the notification bell, never to the failing connector) and the project owner is emailed. Fix the destination, use **Test** to check it, then turn the connector back on, which resets the count, and redeliver what you missed. In-app notifications are never turned off automatically.

### Retention

Deliveries are kept for 30 days and events for 90 days, then deleted automatically. Redeliver within that time, and use the [events API](/docs/api/events) or your own storage if you need a longer history.

## Filters

By default a connector receives every event of the types it subscribes to. Add **Filters** in the connector dialog to narrow that further. An event is sent only if it matches every filter you set; a filter left empty matches everything.

- **Severity**: **High severity only** skips `low` events.
- **Folders**: only events about an asset in one of the chosen folders, or in a folder inside them. Events that aren't about an asset (usage, team and connector events), and assets outside every folder, don't match.
- **Moderation categories**: only events that carry flagged categories, which today means [`asset.flagged`](/docs/api/events#event-asset-flagged), with at least one of the chosen categories. Every other event type doesn't match, so combine this filter with the **Asset flagged** event type.

Filters are applied when the event happens. Changing them doesn't affect deliveries already made.

## Event types

Choose the events your receiver handles. See the [event catalogue](/docs/api/events#event-types) for all types.

### Event facts

Each event includes typed `facts: { key, value, url? }[]`. See [Event facts](/docs/api/events#event-facts) for the registered keys and value types.

## Local development

Use the CLI to forward events to your machine. See [Local development](/docs/guides/local-development).

### Read the events feed

See [Reading the feed](/docs/guides/local-development#read-the-events-feed) for cursor handling and examples.

## In-app notifications

See [In-app notifications](/docs/guides/notifications#in-app-notifications) in Notifications and connectors.

## Slack

See [Slack](/docs/guides/notifications#slack) in Notifications and connectors.

## Email

See [Email](/docs/guides/notifications#email) in Notifications and connectors.
