# Webhooks

> Event-driven integration: catalog, signatures, retries.

<!-- Source: https://docs.actuallycare.com/concepts/webhooks -->

Webhooks turn your integration from pull to push. Instead of polling the API on a schedule and diffing results, you register a URL once and ActuallyCare sends an HTTP POST to it the moment something happens. This page covers the concepts — event catalog, payload, signatures, delivery. For a step-by-step walkthrough, see the [webhook setup guide](/guides/webhook-setup).

## Webhooks or polling?

| Situation | Better fit |
|---|---|
| React to changes as they happen (new lead, escrow update) | Webhooks |
| Periodic full sync or reporting snapshot | Polling the REST API |
| You can't host a public HTTPS endpoint | Polling |
| Low latency matters (lead routing, notifications) | Webhooks |

The usual pattern is both: webhooks for the real-time signal, with an occasional polled reconciliation pass as a safety net.

## The event catalog

The catalog is self-documenting. Ask the API what events exist (all `/v1/webhooks` endpoints take a JWT bearer token — not an API key — and need a broker or system-admin role, see [Managing webhooks](#managing-webhooks)):

```bash
curl "https://api.actuallycare.com/v1/webhooks/events" \
  -H "Authorization: Bearer YOUR_JWT"
```

The response uses the standard envelope: `data` lists every event type with its category, description, and the fields its delivery payload carries, and `meta` totals it up. Trimmed to two entries:

```json
{
  "success": true,
  "data": [
    {
      "event": "escrow.created",
      "category": "escrow",
      "description": "An escrow/transaction was created",
      "payload": ["id", "status", "property_address", "escrow_number"]
    },
    {
      "event": "lead.created",
      "category": "lead",
      "description": "A new lead was created",
      "payload": ["id", "name", "email", "phone", "source", "status"]
    }
  ],
  "meta": {
    "total": 150,
    "categories": ["appointment", "care_status", "client", "..."]
  }
}
```

There are about 150 event types across roughly 27 categories — `escrow.*`, `lead.*`, `client.*`, `listing.*`, `appointment.*`, `care_status.*`, and more. A few representative examples:

| Event | Fires when |
|---|---|
| `escrow.created` | A new escrow is opened |
| `escrow.updated` | An escrow's details change |
| `lead.created` | A new lead enters the pipeline |
| `lead.converted` | A lead becomes a client |
| `client.created` | A new client relationship is created |
| `listing.updated` | A listing's details change |
| `appointment.cancelled` | An appointment is cancelled |
| `care_status.changed` | A relationship's follow-up health changes |

This table is illustrative — **the catalog endpoint is the source of truth.** `GET /v1/webhooks/events/categories` returns the category list if you want to browse by group. When you create a webhook you subscribe it to specific events, or to all of them.

## The payload envelope

Every delivery is a POST with a JSON body in the same envelope:

```json
{
  "event": "escrow.created",
  "team_id": "3f8a1c2e-9d4b-4f6a-8e2d-1b7c5a9e0f3d",
  "timestamp": "2026-06-11T17:32:08.123Z",
  "data": {
    "id": "9c4e7b1a-2f8d-4e3c-a6b5-0d1f2e3a4b5c"
  }
}
```

| Field | Meaning |
|---|---|
| `event` | The event type that fired |
| `team_id` | The team the event belongs to (webhooks are team-scoped) |
| `timestamp` | When the event occurred |
| `data` | The affected record — its shape depends on the event type |

## Verifying signatures

Every delivery is signed so you can confirm it really came from ActuallyCare. Three headers arrive with each request:

| Header | Contents |
|---|---|
| `X-Webhook-Signature` | `sha256=` followed by the HMAC-SHA256 hex digest of the JSON body, computed with your webhook secret (e.g. `sha256=3f5a…`). |
| `X-Webhook-Event` | The event type, same as `event` in the body |
| `X-Webhook-Timestamp` | When the delivery was sent |

Compute the same HMAC over the raw request body and compare with a timing-safe comparison — never a plain string equality:

```javascript
const crypto = require('crypto');
const express = require('express');

const app = express();

function verifySignature(rawBody, signatureHeader, secret) {
  const expectedHex = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  // Header format: "sha256=<hex digest>"
  const receivedHex = (signatureHeader || '').replace(/^sha256=/, '');
  const expected = Buffer.from(expectedHex, 'hex');
  const received = Buffer.from(receivedHex, 'hex');
  return (
    expected.length === received.length &&
    crypto.timingSafeEqual(expected, received)
  );
}

app.post(
  '/webhooks/actuallycare',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.get('X-Webhook-Signature');
    if (!verifySignature(req.body, signature, process.env.WEBHOOK_SECRET)) {
      return res.status(401).send('invalid signature');
    }

    // Acknowledge fast, process async
    res.status(200).send('ok');

    const payload = JSON.parse(req.body.toString('utf8'));
    // handle payload.event, payload.data ...
  }
);
```

Two details that bite people: verify against the **raw** body (a re-serialized JSON object may not match byte-for-byte), and remember to strip the `sha256=` prefix from the header before comparing digests. Your webhook secret is set when you create the webhook (minimum 10 characters).

## Delivery and retries

- ActuallyCare waits up to **30 seconds** for your endpoint to respond. Return a 2xx quickly and do real work asynchronously.
- **Failed deliveries are retried** at 1 minute, 5 minutes, and 15 minutes after the initial attempt (up to 4 attempts total), then given up. A delivery counts as failed on a network error or on a `5xx`, `408`, or `429` response; other `4xx` responses are treated as your endpoint rejecting the event and are **not** retried.
- Retries survive server restarts, and deactivating a webhook cancels its pending retries.
- Delivery is **at-least-once**: your endpoint can see the same event more than once, so process idempotently. The delivery logs — visible in the app, and available via `GET /v1/webhooks/:id/logs` — show every attempt.

Deduplicate on the signature header value — a retried delivery carries the same signature as the original, and unlike `event` + `timestamp` it can't collide when two records fire the same event in the same instant.

## Managing webhooks

Webhooks are **team-scoped** and managed by **broker** and **system-admin** roles. Two ways to manage them:

- **In the app** — Settings, Webhooks tab (`app.actuallycare.com/settings?tab=webhooks`): create endpoints, choose events, and review delivery logs.
- **Via REST** — `GET`/`POST /v1/webhooks` to list and create, `PUT`/`DELETE /v1/webhooks/:id` to update and remove, `GET /v1/webhooks/:id/logs` for delivery history.

Ready to wire one up end to end? Follow the [webhook setup guide](/guides/webhook-setup).
