SiteCallDevelopers

Webhooks

Signed events, in order, at least once.

SiteCall writes each event in the same transaction as the change it reports, then POSTs it to every endpoint of your application that takes its type. The target is under 2 seconds from commit to the first attempt.

Register an endpoint

With your application key (scope webhooks:*). The URL must be https on a public host; SiteCall checks the address again after DNS at every delivery and follows no redirect. Omit eventTypes for every type. An application has at most 20 endpoints.

POST /api/v1/webhooks

curl https://sitecall.ai/api/v1/webhooks \
  -H "authorization: Bearer $SITECALL_API_KEY" \
  -H "idempotency-key: $(uuidgen)" \
  -H "content-type: application/json" \
  -d '{ "url": "https://api.example.com/sitecall/events",
        "eventTypes": ["call.ended", "recording.ready", "transcript.ready"] }'

{ "endpoint": { "id": "…", "url": "https://api.example.com/sitecall/events", "enabled": true, … },
  "signingSecret": "whsec_…" }

The signing secret is shown once, on create and on rotate. Store it with your other secrets.

What a delivery looks like

HeaderValue
x-sitecall-timestampUnix seconds of this attempt.
x-sitecall-signatureHex HMAC-SHA256 of ${timestamp}.${rawBody}, one per live secret, comma-separated during a rotation.
x-sitecall-event-idThe event id (the body's id).
x-sitecall-event-typeThe event type.

The body is one event as JSON (see Events and the event schemas). Answer any 2xx within 10 seconds; anything else, a timeout or a redirect is a failed attempt.

Verify the signature

Compute HMAC-SHA256 over the timestamp header, a dot, and the raw body exactly as received, keyed by the whole secret string (whsec_… included) as UTF-8. Accept the delivery when any comma-separated value matches, compared in constant time, and refuse a timestamp more than 5 minutes from your clock: the timestamp is inside the MAC, so a captured delivery cannot be replayed later.

with @sitecall/cli

import { verifyWebhook, SiteCallWebhookError } from "@sitecall/cli";

export async function POST(request: Request) {
  const body = await request.text(); // the raw body, before JSON parsing
  let event;
  try {
    event = await verifyWebhook({
      body,
      headers: request.headers,
      // During a rotation, both the new and the outgoing secret.
      secrets: [process.env.SITECALL_WEBHOOK_SECRET, process.env.SITECALL_WEBHOOK_SECRET_PREVIOUS].filter(
        (secret): secret is string => Boolean(secret),
      ),
    });
  } catch (error) {
    if (error instanceof SiteCallWebhookError) return new Response(null, { status: 400 });
    throw error;
  }
  if (await alreadyHandled(event.id)) return new Response(null, { status: 204 });
  switch (event.type) {
    case "call.ended":
      await recordCall(event.callId, event.call, event.durationSeconds);
      break;
    case "unrecognized":
      break; // a type added after this client version: acknowledge and skip
  }
  return new Response(null, { status: 204 });
}

any Node server, no SiteCall package

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifySiteCall(rawBody: string, headers: Headers, secrets: string[]): boolean {
  const timestamp = headers.get("x-sitecall-timestamp") ?? "";
  const signatures = (headers.get("x-sitecall-signature") ?? "").split(",").map((s) => s.trim());
  if (!/^\d{1,12}$/.test(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  for (const secret of secrets) {
    const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
    for (const value of signatures) {
      const presented = Buffer.from(value, "hex");
      if (presented.length === expected.length && timingSafeEqual(presented, expected)) return true;
    }
  }
  return false;
}

Ordering and duplicates

Retries and parking

A failed attempt is retried after 1 second, doubling to at most 5 minutes. 24 hours after its first attempt, the event is parked with the rest of its stream. A failing stream stops alone: your other calls' and numbers' streams keep flowing, and call handling never waits on deliveries. GET /api/v1/webhooks/{id} shows the endpoint's pending and parked counts and its last error.

Replay

POST /api/v1/webhooks/{id}/replay resumes every parked stream (the default, { "scope": "parked" }), or redelivers the events that occurred in a range of at most 7 days, or a list of up to 100 event ids. streamKey narrows either to one stream. Replayed events keep their sequence, so each stream redelivers in order.

await sitecall.webhooks.replay(endpointId);                  // parked streams
await sitecall.webhooks.replay(endpointId, {
  scope: "range",
  since: "2026-10-12T08:00:00Z",
  until: "2026-10-12T09:00:00Z",
  streamKey: `call:${callId}`,
});

For gap recovery without redelivery, page the event log instead.

Rotate the secret

POST /api/v1/webhooks/{id}/rotate-secret returns a new secret. The outgoing one keeps signing too for overlapSeconds (default 24 hours, at most 7 days; 0 ends it at once), so deliveries carry two signatures until then. Deploy the new secret beside the old, then drop the old one.