> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cerca.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Receiving webhooks

> Subscribe to events, verify deliveries, and handle retries idempotently.

This guide walks through the full receive path: create a subscription, accept a delivery, verify the signature, dedupe retries, and round-trip a synthetic `webhook.test` event before going live. For the mental model — fleet scope, delivery semantics, ordering — see [Webhooks](/concepts/webhooks).

## Subscribe

Create the subscription against the fleet that owns the agents you want to hear about. Pass the HTTPS endpoint that will receive deliveries and, optionally, the events to filter to. Omit `events` to subscribe to every non-test event.

```sh theme={null}
curl https://api.cerca.dev/fleets/$FLEET_ID/webhooks \
  -H "Authorization: Bearer $CERCA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/cerca",
    "events": ["thread.completed", "approval.requested"]
  }'
```

The response contains the subscription metadata plus the signing secret:

```json theme={null}
{
  "id": "webhook_abc123",
  "fleetId": "fleet_internal_ops",
  "url": "https://example.com/webhooks/cerca",
  "events": ["thread.completed", "approval.requested"],
  "enabled": true,
  "createdAt": "2026-05-04T12:00:00.000Z",
  "updatedAt": "2026-05-04T12:00:00.000Z",
  "secret": "whsec_abc123"
}
```

The `secret` field is returned **only on create and rotate**. Store it somewhere you can read from your receiver — a secrets manager, Workers secret, or environment variable. Cerca will not show it again.

## Receive

Every delivery is a `POST` with a JSON body and four headers you care about:

| Header                | Description                                                        |
| --------------------- | ------------------------------------------------------------------ |
| `X-Agent-Event`       | The event type, e.g. `thread.completed`.                           |
| `X-Agent-Delivery-Id` | Stable ID for this delivery. The same ID is reused across retries. |
| `X-Agent-Timestamp`   | Unix seconds the signature was computed at.                        |
| `X-Agent-Signature`   | `sha256=<hex>` HMAC of `{timestamp}.{payload}`.                    |

A minimal Cloudflare Worker handler looks like this:

```ts theme={null}
export default {
  async fetch(request: Request, env: { CERCA_WEBHOOK_SECRET: string }) {
    if (request.method !== "POST") {
      return new Response("Method not allowed", { status: 405 });
    }

    const signatureHeader = request.headers.get("X-Agent-Signature");
    const timestamp = request.headers.get("X-Agent-Timestamp");
    const deliveryId = request.headers.get("X-Agent-Delivery-Id");
    const eventType = request.headers.get("X-Agent-Event");
    const payload = await request.text();

    if (!signatureHeader || !timestamp || !deliveryId || !eventType) {
      return new Response("Missing webhook headers", { status: 400 });
    }

    const ok = await verifyWebhook({
      payload,
      signatureHeader,
      timestamp,
      secret: env.CERCA_WEBHOOK_SECRET,
    });
    if (!ok) {
      return new Response("Invalid signature", { status: 401 });
    }

    const event = JSON.parse(payload);
    await handleEvent({ eventType, deliveryId, event });

    return new Response(null, { status: 204 });
  },
};
```

Return a `2xx` status to acknowledge the delivery. The runtime retries once on network errors, `429` responses, and `5xx` responses; other non-2xx responses (`400`, `401`, `403`, `404`, etc.) are recorded as a failure with no retry, so use `5xx` if you want the runtime to try again.

## Verify

The signing string is `${timestamp}.${payload}` — the value of `X-Agent-Timestamp`, a single dot, and the raw request body. Strip the `sha256=` prefix from the header value before comparing, reject deliveries whose timestamp drifts too far (5 minutes is a reasonable default), and compare in constant time so a bad signature does not leak timing.

```ts theme={null}
async function verifyWebhook(input: {
  payload: string;
  signatureHeader: string;
  timestamp: string;
  secret: string;
}): Promise<boolean> {
  const skewSeconds = Math.abs(
    Math.floor(Date.now() / 1000) - Number(input.timestamp),
  );
  if (!Number.isFinite(skewSeconds) || skewSeconds > 5 * 60) {
    return false;
  }

  const provided = input.signatureHeader.startsWith("sha256=")
    ? input.signatureHeader.slice("sha256=".length)
    : input.signatureHeader;

  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(input.secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const signed = await crypto.subtle.sign(
    "HMAC",
    key,
    new TextEncoder().encode(`${input.timestamp}.${input.payload}`),
  );
  const expected = Array.from(new Uint8Array(signed))
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");

  return constantTimeEqual(expected, provided);
}

function constantTimeEqual(a: string, b: string): boolean {
  // SHA-256 hex is always 64 chars, so the expected length is public and
  // the early-return does not leak secret-dependent timing here.
  if (a.length !== b.length) {
    return false;
  }
  let mismatch = 0;
  for (let i = 0; i < a.length; i += 1) {
    mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return mismatch === 0;
}
```

Verify against the raw body bytes that arrived. Reading the body once and reusing the same string for both the signature check and `JSON.parse` is the safest pattern — re-serializing parsed JSON will not match the signature.

## Handle retries idempotently

Deliveries are at-least-once, so your handler must tolerate seeing the same event twice. The `X-Agent-Delivery-Id` header is stable across retries of a given delivery — claim it atomically and skip work you have already done:

```ts theme={null}
async function handleEvent(input: {
  eventType: string;
  deliveryId: string;
  event: unknown;
}) {
  const claimed = await claimDeliveryId(input.deliveryId);
  if (!claimed) {
    return;
  }

  await processEvent(input.eventType, input.event);
}
```

Ordering is not guaranteed either. When ordering matters, reconcile against your own state — for example, ignore a `thread.status.changed` event if you have already recorded a later state for that thread.

## Test

The test endpoint fires a synthetic `webhook.test` event end-to-end against your URL using the real signing secret. It is the fastest way to confirm the full receive-and-verify path works before any real event fires.

```sh theme={null}
curl -X POST https://api.cerca.dev/fleets/$FLEET_ID/webhooks/$WEBHOOK_ID/test \
  -H "Authorization: Bearer $CERCA_API_KEY"
```

The response reports the structured delivery result:

```json theme={null}
{
  "success": true,
  "statusCode": 204,
  "error": null
}
```

A `success: false` result means the runtime could not reach your handler or got a non-`2xx` response. The delivered event has `event: "webhook.test"` and an empty `data` object, so ignore unknown event types in your handler rather than treating them as errors.

`webhook.test` is reserved for the test endpoint and cannot be added to a subscription's `events` array on create or update.

## Rotate

Rotate the secret if you suspect it leaked, on a regular schedule, or before retiring an old receiver. Rotation issues a new secret and invalidates the old one immediately.

```sh theme={null}
curl -X POST https://api.cerca.dev/fleets/$FLEET_ID/webhooks/$WEBHOOK_ID/rotate \
  -H "Authorization: Bearer $CERCA_API_KEY"
```

The response shape matches create — the new secret is returned as `secret` and is not retrievable later. To rotate without dropped deliveries, configure your receiver to accept either the old secret or the new one for an overlap window, deploy that change, run the rotate call, and remove the old secret once you have confirmed nothing is still signing with it.
