Reference
Webhook payload
A webhook channel is one HTTPS URL per project, added under Alerts → Channels in the app. camada sends one POST per alert event, JSON body, signed. Any 2xx response counts as delivered; anything else is retried. Times in the body are ISO 8601 UTC.
Request headers
| Header | Value |
|---|---|
| content-type | Always application/json. |
| x-camada-event | The event id, e.g. block.applied. Route on this without parsing the body. |
| x-camada-delivery | Delivery id, the same value as delivery_id in the body. Use it to deduplicate. |
| x-camada-signature | t=<unix seconds>,v1=<hex HMAC-SHA256>. Can carry more than one v1; accept the request if any one matches. See below. |
Payload
Every event uses the same envelope. entity is null for events that are not about one address or session (quota, payment, learning); each link in links is absolute or null. data carries the fields specific to the event. schema_version is 1.
{
"schema_version": 1,
"event": "block.applied",
"severity": "warning",
"occurred_at": "2026-09-03T14:22:07.311Z",
"delivery_id": "dlv_7f3c1a9e2b40",
"org": { "id": "org_2ZKq", "name": "Acme" },
"project": { "id": "prj_8Ha2", "name": "Production", "slug": "acme-production", "env": "production" },
"entity": { "key_type": "ip", "key": "203.0.113.66" },
"verdict": "malicious",
"category": "scraping",
"confidence": 0.94,
"action": "blocked",
"summary": "1,284 requests in 4 minutes across 37 product pages, no beacon, headless fingerprint.",
"evidence": [
"no beacon post on any of 37 page views",
"TLS fingerprint matches a known headless client",
"request interval standard deviation 41 ms"
],
"links": {
"entity": "https://my.camada.app/activity/entities/ip/203.0.113.66",
"run": "https://my.camada.app/activity/runs/run_5512",
"allow": "https://my.camada.app/lists?allow=203.0.113.66",
"block": "https://my.camada.app/lists?tab=blocks&block=203.0.113.66",
"project": "https://my.camada.app/"
},
"data": { "ttl_hours": 24, "source": "verdict" }
}Compatibility
Inside schema_version: 1 fields are only added, never removed or renamed — so parse leniently and ignore what you do not know. A breaking change bumps schema_version, and the old version keeps being delivered for 90 days after the new one ships, which is your window to migrate.
Event catalog v1
| Event | Default severity | Meaning |
|---|---|---|
| block.applied | warning | A block was written and published to your snapshot. |
| block.recommended | info | A block was recommended during the learning window, not enforced. |
| verdict.suspicious | info | The analyst wrote a suspicious or malicious verdict that was not blocked. |
| attack.spike | critical | Blocked plus recommended activity in five minutes went far over the trailing hour. |
| learning.ended | info | The learning window closed and is waiting for your review. |
| list.changed | info | Someone changed the allow-list or a manual block. |
| key.rotated | warning | A project key was rotated; the previous pair is in its grace period. |
| key.old_still_in_use | warning | The previous key is still being used after a rotation. |
| quota.threshold | warning | The organization passed 80% of its included events for the period. |
| quota.exceeded | warning | The organization passed 100% of its included events. Traffic is never blocked. |
| run.failed | critical | An analysis run failed after its last retry. |
| payment.failed | critical | Stripe could not charge the card on file. |
| test | info | Sent by "Send test" on a channel. Same shape, no real entity. |
Severity is a default: you set it per event per channel when you route it, and you choose immediate, hourly or daily delivery there too.
Verifying the signature
Each webhook channel has a secret, shown once when you create it and rotatable at any time. Every request carries:
x-camada-signature: t=1788393727,v1=9f2c...c41dv1 is the hex HMAC-SHA256 of the string <t>.<raw body> under your channel secret, where t is the Unix timestamp in seconds from the same header. Reject the request when |now − t| is more than 300 seconds, and compare the digests in constant time. Sign over the bytes you received, before any JSON parse or re-serialisation.
The header can carry more than one v1: for 24 hours after you rotate a secret, camada signs with both the old and the new one (t=…,v1=…,v1=…), so you can deploy the new secret without dropping deliveries. The order is not promised. Accept the request if any one v1 matches, as the sample does.
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.CAMADA_WEBHOOK_SECRET;
// Verify over the RAW body, before any JSON parsing.
app.post('/camada', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('x-camada-signature') ?? '';
const pairs = header.split(',').map((kv) => kv.split('='));
const t = Number(pairs.find(([k]) => k === 't')?.[1]);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(400);
const expected = createHmac('sha256', SECRET).update(t + '.' + req.body).digest('hex');
const want = Buffer.from(expected, 'hex');
// The header can carry more than one v1 (see below): accept the request if any one matches.
const ok = pairs
.filter(([k]) => k === 'v1')
.some(([, v]) => {
const got = Buffer.from(v ?? '', 'hex');
return got.length === want.length && timingSafeEqual(got, want);
});
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8'));
console.log(event.event, event.delivery_id);
res.sendStatus(200); // any 2xx counts as delivered
});Retries
A non-2xx response, a timeout or a transport error is retried up to five times with a growing delay: 30 seconds, 1 minute, 5 minutes, 15 minutes, 60 minutes. Deliveries are idempotent per event and channel, but a retry can still arrive after your first response was lost — deduplicate on delivery_id. A channel that has been failing for 24 hours is disabled automatically and the organization's admins get an email.
Noise control
- The same event for the same entity is sent once per 60 minutes; repeats are recorded without a delivery.
- Each channel takes a limited number of immediate deliveries an hour, 20 by default and editable per channel between 1 and 1,000 under Alerts → Channels; anything over the cap rolls into that channel's digest instead of being dropped.
- A route can be hourly or daily instead of immediate: one summary per channel per window, with counts by event and the top entities.
- Mute silences a project until a time you pick. Critical events still deliver.
Slack and email channels
A Slack channel takes an incoming webhook URL and receives Block Kit, not this JSON: a header, a section with the entity, verdict, confidence and project as fields, a context line, and Allow / Block buttons linking into the app. An email channel goes to named addresses or to every member of the organization, sent through Postmark from alerts@camada.app, subject [camada] <severity> <event>. Both carry the same facts as the JSON above.
Webhook channels are on Growth and above, Slack from Starter, email on every plan — see pricing. The Channels tab has a "Send test" button that pushes the test event through the real path and shows you the response code.