Webhooks
Webhooks are how the platform pushes events to your systems — deal advanced, post published, agent run completed, planned action confirmed. Everything you can observe through polling is available as a push event.
Configuring webhooks
Settings → Tenant → Webhooks → Add endpoint. You provide:
- URL — an HTTPS endpoint that accepts POST.
- Secret — a shared secret for signature verification (we generate a random one; rotate any time).
- Events — a subscription list (see Event types).
- Active — flip off for maintenance without losing the configuration.
You can have up to 10 webhook endpoints per tenant. Each can subscribe to a different event set.
Delivery model
- POST JSON. We never use GET; we never query-string event data.
- At-least-once delivery. We retry on non-2xx response codes with exponential backoff (1s, 5s, 30s, 5m, 1h — five attempts total over ~1.5 hours).
- Ordered per entity but not globally. Two events on the same deal arrive in order; events on different deals may interleave.
- Timeout: 10 seconds. Your handler should ACK fast and process async.
Signature verification
Every request includes:
X-CCD-Signature: t=<unix_seconds>,v1=<hmac_sha256_hex>
X-CCD-Webhook-Id: wh_01HZ...
X-CCD-Delivery-Id: dlv_01HZ...
v1 is computed as:
HMAC-SHA256(
secret,
t + '.' + raw_request_body
)
To verify (Node example):
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyCcdSignature(
rawBody: string,
header: string,
secret: string,
toleranceSeconds = 300,
): boolean {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=')),
);
const t = Number(parts.t);
if (!Number.isInteger(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
return (
parts.v1.length === expected.length &&
timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))
);
}
The 5-minute tolerance prevents replay attacks. Reject anything older.
Payload shape
All webhooks share an envelope:
{
"id": "evt_01HZ...",
"type": "deal.advanced",
"occurred_at": "2026-06-06T14:32:11Z",
"tenant_id": "ten_01HZ...",
"data": { ... event-specific payload ... },
"previous_data": { ... when applicable ... }
}
previous_data is only set on update events; it shows the
pre-change state so you can compute deltas without round-tripping
back to our API.
Event types
CRM
| Event | Fires when |
|---|---|
contact.created | New contact added |
contact.updated | Any field changed |
contact.deleted | Soft-deleted |
deal.created | New deal opened |
deal.advanced | Stage changed (data.from_stage, data.to_stage) |
deal.won | Stage → closed_won |
deal.lost | Stage → closed_lost |
deal.updated | Any other field change |
activity.logged | Any new activity (email, call, meeting, note) |
Content
| Event | Fires when |
|---|---|
post.scheduled | Confirmed for future publish |
post.published | Successfully sent to networks |
post.failed | All network attempts failed |
post.engagement_update | Significant engagement change (e.g. crossed an order-of-magnitude threshold) |
Agent
| Event | Fires when |
|---|---|
agent_run.started | Orchestrator boot |
agent_run.completed | Final answer streamed |
agent_run.failed | Exhausted retries / errored |
planned_action.proposed | Agent emitted a planned action |
planned_action.confirmed | User confirmed |
planned_action.discarded | User discarded |
planned_action.executed | Worker finished executing |
Tenant + lifecycle
| Event | Fires when |
|---|---|
member.invited | New invite sent |
member.joined | Invite accepted |
member.removed | Removed from tenant |
integration.connected | OAuth or API key connected |
integration.disconnected | Revoked |
tenant.billing_event (cloud) | Subscription change |
Replay + idempotency
Each event has a unique id. Your handler should treat duplicate
ids as a no-op — under our at-least-once contract, you'll
occasionally see the same event twice (network blip during ack,
retry storm).
Recent events are queryable via GET /api/webhooks/events? since=<ts> for backfill or recovery. Useful when your endpoint
was down — pull the gap explicitly rather than waiting for our
retries.
Testing
Two paths:
- The events tester — Settings → Tenant → Webhooks → Send test event. Generates a synthetic event of any type and posts it to your endpoint. Useful for shaping your handler.
- The webhook tunnel (self-host dev mode) —
./ccd tunnel startproxies webhook events to a local URL. Equivalent to ngrok for the webhook path, no third-party.
Recent deliveries
For each endpoint, Settings → Tenant → Webhooks → endpoint → Deliveries shows the last 200 attempts with:
- Event type
- Status code
- Latency
- Number of retry attempts so far
- Truncated payload (full payload available on click)
Useful when debugging why your handler is rejecting events.
When NOT to use webhooks
For data you need to react to instantly + reliably, webhooks are good. For data you need to query against (counts, joins, ad-hoc filtering), use the REST API — webhooks are an event stream, not a database.
For data your customers need to see live in their browser, use the platform's own Realtime subscriptions (Supabase Realtime, wrapped by the in-app realtime layer) — webhooks have higher latency and aren't intended for end-user UI.
Read next
- REST API — the pull-based companion.
- Tasks concept — the abstraction behind
most
planned_action.*events.