---
title: "Webhook reference: events, HMAC signature, retries · camada"
description: "camada webhook reference: the alert event catalog, the JSON payload, the x-camada-signature HMAC-SHA256 scheme with a Node snippet, and the retries."
url: https://www.camada.app/docs/webhooks
last_updated: 2026-10-30
---

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.

POST body

```json
{
  "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:

header

```http
x-camada-signature: t=1788393727,v1=9f2c...c41d
```

`v1` 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.

Node 20+ / Express

```js
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](https://www.camada.app/pricing). The Channels tab has a "Send test" button that pushes the `test` event through the real path and shows you the response code.

## Sitemap

[Every page on this site](https://www.camada.app/sitemap.md)
