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.
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 for the completed moderation decision. Read
data.asset.statusand matchdata.assetIdto your submission. - 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.
Create a connector
- Open Portal › Webhooks & Connectors and add a connector.
- Choose a type and give it a name. The steps below are for a webhook; see Slack and Email for the other types.
- 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. - 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 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:
{
"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. |
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), 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.
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 });
}
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 Portal › Webhooks & 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), 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 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
lowevents. - 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, 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 for all types.
Event facts
Each event includes typed facts: { key, value, url? }[]. See Event facts for the registered keys and value types.
Local development
Use the CLI to forward events to your machine. See Local development.
Read the events feed
See Reading the feed for cursor handling and examples.
In-app notifications
See In-app notifications in Notifications and connectors.
Slack
See Slack in Notifications and connectors.
See Email in Notifications and connectors.