# Webhooks

## Outbound: events sent to you

Add an endpoint in Settings → Webhooks (or `POST /v1/webhooks`) and choose the events it receives, or `*` for all.
The signing secret (`whsec_…`) is shown once. Endpoints must use https and a public address.

Events: `account.dormant`, `account.declining`, `nps.answered`, `invoice.overdue`, `record.created`, `record.updated`, `record.archived`, `deal.won`, `deal.lost`, `deal.stage_changed`, `activity.created`, `meeting.recorded`, `meeting.upcoming`, `insight.captured`, `run.finished`, `approval.needed`, `account.state_changed`, `signal.fired`, `sequence.replied`, `booking.no_show`, `quote.stalled`, `call.completed`.

Each delivery is a POST with a JSON body, signed per [Standard Webhooks](https://www.standardwebhooks.com):

- `webhook-id`: the event's id (use it to drop duplicates);
- `webhook-timestamp`: seconds since 1970;
- `webhook-signature`: `v1,` and the base64 HMAC-SHA256 of `id.timestamp.body`, keyed with the secret after `whsec_`.
  During the 24 hours after you rotate the secret, both secrets sign, separated by a space.

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

// body: the raw request body, as a string, before any JSON parsing.
function verify(secret, headers, body) {
  const id = headers["webhook-id"], ts = headers["webhook-timestamp"], sigs = headers["webhook-signature"];
  if (!id || !ts || !sigs) return false;
  const t = Number(ts);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false; // more than five minutes off
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const want = createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest();
  return sigs.split(" ").some((sig) => {
    const got = Buffer.from(sig.replace(/^v1,/, ""), "base64");
    return got.length === want.length && timingSafeEqual(got, want);
  });
}
```

Answer with any 2xx within 15 seconds. A failed delivery is tried again after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 10 hours
(7 more attempts), then marked failed. Deliveries are kept for 30 days (`GET /v1/webhooks/{id}/deliveries`)
and can be sent again (`POST /v1/webhook-deliveries/{id}/resend`). Pause an endpoint with `enabled: false`.

## Inbound: start an agent from another system

Give an agent a webhook trigger (`POST /v1/agents/{id}/triggers`, or in the agent's settings). Its URL and secret are
shown once. The other system posts JSON to that URL with `Authorization: Bearer <secret>`. The trigger maps the
body's fields to the agent's inputs; the body is data for the run, never instructions. Send an `Idempotency-Key` header
so a retried call doesn't start a second run. Zapier, Make and n8n work the same way. Recent firings, including duplicates and ones refused because the agent
isn't live, are at `GET /v1/triggers/{id}/fires`; `POST /v1/triggers/{id}/rotate-secret` replaces the secret.

Approvals, limits and what an agent may do alone: [Approvals and policy](https://www.closefinch.com/docs/approvals).
