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.
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.
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.
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:
{
"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:
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.
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:
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.
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:
{
"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.
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.