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

EventFires when
contact.createdNew contact added
contact.updatedAny field changed
contact.deletedSoft-deleted
deal.createdNew deal opened
deal.advancedStage changed (data.from_stage, data.to_stage)
deal.wonStage → closed_won
deal.lostStage → closed_lost
deal.updatedAny other field change
activity.loggedAny new activity (email, call, meeting, note)

Content

EventFires when
post.scheduledConfirmed for future publish
post.publishedSuccessfully sent to networks
post.failedAll network attempts failed
post.engagement_updateSignificant engagement change (e.g. crossed an order-of-magnitude threshold)

Agent

EventFires when
agent_run.startedOrchestrator boot
agent_run.completedFinal answer streamed
agent_run.failedExhausted retries / errored
planned_action.proposedAgent emitted a planned action
planned_action.confirmedUser confirmed
planned_action.discardedUser discarded
planned_action.executedWorker finished executing

Tenant + lifecycle

EventFires when
member.invitedNew invite sent
member.joinedInvite accepted
member.removedRemoved from tenant
integration.connectedOAuth or API key connected
integration.disconnectedRevoked
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:

  1. 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.
  2. The webhook tunnel (self-host dev mode) — ./ccd tunnel start proxies 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.