Forward your project's events to a local handler with the CalmLens CLI, verify their signatures, and see how test sends and delivery retries behave.
·6 min read
Webhooks
CLI
Local Development
Webhook connectors in CalmLens deliver to a public HTTPS URL. CalmLens rejects private IP addresses and localhost names as destinations, so a handler running on your laptop can't receive deliveries directly.
Release 2026.5 adds the calmlens CLI for that case. It reads your project's events through the API and forwards each one to a URL you choose. Your handler receives the same body, headers and signature it would get from a real delivery, and nothing needs to reach your machine from outside.
Forward events to your handler
The CLI ships with the JavaScript SDK, so in a project that already depends on @calmlens/sdk you can run it with npx:
It needs three values, read from the environment or from the file passed to --env-file:
Variable
Used for
CALMLENS_API_KEY
Reading the project's events
CALMLENS_PROJECT_ID
The project to read from
CALMLENS_WEBHOOK_SECRET
Signing each forwarded request
Each one can also be passed as a flag (--api-key, --project-id, --secret). If you don't provide a secret, the CLI generates one, prints it, and asks you to set it in your app. The key and project are checked before the CLI starts forwarding, so a wrong key fails straight away instead of after the first event.
Use --events asset.classified,asset.flagged to forward only the types your handler cares about. Without it, every event is forwarded. Each forwarded request is printed with its event type, event ID, the status your handler returned and how long it took. If nothing is listening on the port, the line says so and suggests checking that your server is running.
Create events on demand
Waiting for real content to arrive is slow. npx calmlens trigger moderates a short test input that CalmLens recognises and classifies as harassment, so you get the full set of asset events for a flagged upload. You can also pass hate or criminal-activity, or clean for text that should be approved.
A trigger is a real upload in your project. It goes through your moderation policy like any other asset and counts as one upload towards your usage.
Verify signatures the same way you will in production
Because the CLI signs with HMAC-SHA256 and your secret, the verification code in your handler is the code you'll ship. Read the raw body before parsing it, then pass it with the signature headers to the SDK helper, which compares signatures in constant time:
import { CalmLensError, parseWebhookEvent } from"@calmlens/sdk";
exportasyncfunctionPOST(request:Request) {
let event;
try {
// Verifies the signature over the raw body, then parses it. event =awaitparseWebhookEvent({
payload: await request.text(),
headers: request.headers,
secret: process.env.CALMLENS_WEBHOOK_SECRET!,
});
} catch (error) {
if (CalmLensError.isCalmLensError(error)) returnnewResponse("Invalid delivery", { status: 401 });
throw error;
}
// Queue the work, keyed by event.id, then respond quickly.returnnewResponse(null, { status: 204 });
}
If you parse the JSON and serialise it again before verifying, key order or whitespace can change, and the signature no longer matches the bytes you are checking. Running against the CLI catches that mistake locally: a handler that verifies the wrong bytes returns 401 for every forwarded event.
What the CLI doesn't do
The CLI only forwards events created after it starts. It asks the API for a cursor that marks the current time and reads forward from there, so restarting it doesn't replay history.
Each event is forwarded once. If your handler returns an error or isn't running, the CLI prints the failure and moves on. It doesn't follow the webhook retry schedule described below, so retry behaviour has to be tested against a real connector.
Forwarding works by polling. By default the CLI checks for new events every two seconds, which you can change with --interval (at least one second). If CalmLens can't be reached, it backs off and keeps trying, up to 30 seconds between attempts. Errors that need you to change something, such as an invalid API key or a key for a different project, stop the command instead.
Read the events feed yourself
calmlens listen is built on a new endpoint, GET /projects/:projectId/events, and you can call it from your own scripts with client.events.list(). Call it without after to get a cursor for the current time, then pass the returned cursor back to receive newer events, oldest first. When a response has hasMore: true, request again straight away.
let { cursor } =await client.events.list();
setInterval(async () => {
const page =await client.events.list({ after: cursor });
for (const event of page.events) console.log(event.type, event.asset?.status);
cursor = page.cursor;
}, 2000);
A page holds 50 events by default and up to 100 with limit. We still recommend webhook connectors in production, since the feed relies on you to poll. It suits local development, scripts and backfills.
Test sends from the portal
Once your handler is deployed, the connector's Test action sends a synthetic event of the type you choose to the connector's real URL. It is signed with the connector's secret, so it checks the URL, your verification and your response together, and the portal shows whether your endpoint accepted it.
Test events use example data and carry the registered { "key": "test", "value": true } entry in their facts. Check for it if your handler should acknowledge test events without acting on them. A test send is attempted once and is not retried.
Retries for failed deliveries
Also in 2026.5, CalmLens retries failed webhook deliveries. A delivery gets up to 7 attempts in total, with waits of about 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours between them, which comes to roughly 20 hours.
Not every failure is retried. Network errors, timeouts, 408, 425, 429 and 5xx responses are. Other responses, including redirects and other 4xx codes, fail the delivery straight away. A Retry-After header on a 429 or 503 response can lengthen the next wait, up to 12 hours.
Every attempt of a delivery carries the same delivery ID in x-calmlens-delivery and the same event id. Delivery is at least once, so your handler can occasionally see an event twice. Store the event id and skip events you've already processed. The deliveries list for each connector shows the attempt count, the last response or error and the next retry time.
Return a 2xx response within 10 seconds and move slower work to your own queue. A handler that does its work inline risks timing out, which counts as a failed attempt and leads to the event being sent again.
We use essential cookies to keep you signed in and the site secure. With your permission, we'll also use functional cookies to remember preferences such as your layout and documentation language, and to offer Google One Tap sign-in. We don't use analytics or advertising cookies. Manage cookies