# Set up webhooks

> Register a webhook, verify signatures, and process events reliably.

<!-- Source: https://docs.actuallycare.com/guides/webhook-setup -->

Webhooks push events to your application the moment they happen — a lead comes in, an escrow status changes, an appointment is booked — instead of you polling the API. This guide takes you from zero to a verified, retry-safe webhook receiver.

For the conceptual background (what the catalog covers, payload envelope, delivery semantics), see [Webhooks](/concepts/webhooks).

> **Who can do this:** webhook management (creating webhooks, listing the event catalog, reading delivery logs) requires a **broker** or **system-admin** role. Agents on a team will get `403` on every `/v1/webhooks` endpoint — ask your broker to set the webhook up, or to run these steps with you.
>
> **Auth:** the `/v1/webhooks` endpoints accept a **JWT bearer token only** (from [`POST /v1/auth/login`](/api/authentication)) — an `X-API-Key` header gets `401` here. The examples below assume the token is in `$ACTUALLYCARE_JWT`.

## Step 1: Discover the events you can subscribe to

The event catalog is self-documenting. Ask the API what's available:

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

The response uses the standard envelope, with `data` containing the full catalog — about 150 event types across roughly 27 categories, following an `entity.action` naming pattern (`escrow.*`, `lead.*`, `client.*`, `listing.*`, `appointment.*`, `care_status.*`, and more).

There's also a grouped view:

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

Pick the specific events you care about, or subscribe to `all` if you want everything.

## Step 2: Register your webhook

You can register in the app or via the API. Either way you'll provide three things: the URL to deliver to, the events to subscribe to, and a secret (minimum 10 characters) used to sign every delivery.

### In the app

1. Open **Settings → Webhooks** (direct link: `https://app.actuallycare.com/settings?tab=webhooks`).
2. Add a webhook with your endpoint URL, the events you chose in Step 1, and a secret.

The Webhooks tab is available to broker and system-admin roles, and webhooks are team-scoped — one registration covers events for your whole team.

### Via the API

```bash
curl -X POST "https://api.actuallycare.com/v1/webhooks" \
  -H "Authorization: Bearer $ACTUALLYCARE_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/webhooks/actuallycare",
    "events": ["lead.created", "escrow.updated"],
    "secret": "a-long-random-secret-string"
  }'
```

Use exact event names from the Step 1 catalog. The response envelope's `data` contains your new webhook, including its `id` — keep that for managing it later (`PUT /v1/webhooks/:id`, `DELETE /v1/webhooks/:id`, and the logs endpoint in Step 7).

Generate the secret randomly (for example `openssl rand -hex 32`) and store it in an environment variable on your server. You'll need it to verify signatures next.

## Step 3: Receive and verify deliveries

Every delivery is an HTTP POST with this payload envelope:

```json
{
  "event": "lead.created",
  "team_id": "...",
  "timestamp": "2026-06-11T17:42:11.000Z",
  "data": {}
}
```

And these headers:

| Header | Contents |
|---|---|
| `X-Webhook-Signature` | `sha256=` followed by the HMAC-SHA256 hex digest of the JSON body, keyed with your secret (e.g. `sha256=3f5a…`). |
| `X-Webhook-Event` | The event name, so you can route before parsing the body. |
| `X-Webhook-Timestamp` | When the delivery was sent. |

**Always verify the signature** with a timing-safe comparison, computed over the raw request body exactly as received — re-serializing the parsed JSON can change byte order or whitespace and break the match.

### Express

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

const app = express();
const SECRET = process.env.ACTUALLYCARE_WEBHOOK_SECRET;

// Keep the raw bytes for signature verification.
app.use(express.json({
  verify: (req, res, buf) => { req.rawBody = buf; },
}));

function verifySignature(req) {
  // Header format: "sha256=<hex digest>"
  const received = (req.get('X-Webhook-Signature') || '').replace(/^sha256=/, '');
  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(req.rawBody)
    .digest('hex');

  const a = Buffer.from(received, 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post('/webhooks/actuallycare', (req, res) => {
  if (!verifySignature(req)) {
    return res.status(401).send('invalid signature');
  }

  res.status(200).send('ok');                  // acknowledge first
  setImmediate(() => processEvent(req.body));  // work after responding
});

function processEvent(payload) {
  console.log(`${payload.event} for team ${payload.team_id} at ${payload.timestamp}`);
}

app.listen(3000);
```

### Flask

```python
import hashlib
import hmac
import os

from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["ACTUALLYCARE_WEBHOOK_SECRET"].encode()

def verify_signature(req):
    # Header format: "sha256=<hex digest>"
    received = req.headers.get("X-Webhook-Signature", "").removeprefix("sha256=")
    expected = hmac.new(SECRET, req.get_data(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(received, expected)

@app.route("/webhooks/actuallycare", methods=["POST"])
def webhook():
    if not verify_signature(request):
        return "invalid signature", 401

    payload = request.get_json()
    # Hand off to a queue or worker — don't do slow work in the request.
    enqueue_for_processing(payload)
    return "ok", 200
```

## Step 4: Respond fast

Deliveries time out after **30 seconds**, and a timeout counts as a failed delivery. Don't make ActuallyCare wait while you update your database, call another API, or send an email.

The pattern: validate the signature, return `200`, then do the real work asynchronously — `setImmediate` in Node, a task queue or background worker in Python. Both examples above follow it.

## Step 5: Handle retries idempotently

Delivery is **at-least-once**. A failed delivery — a network error, or a `5xx`, `408`, or `429` response from your endpoint — is retried at 1, 5, and 15 minutes after the initial attempt (up to 4 attempts total), then given up. Other `4xx` responses are treated as a rejection and are **not** retried, so don't return `400` for events you simply haven't handled yet. Retries survive server restarts; deactivating the webhook cancels any pending retries. The delivery logs (Step 7) show every attempt.

At-least-once means your endpoint **will** occasionally see the same event twice — for example if your handler responded slowly and the delivery timed out after you'd already started processing. A retried delivery carries the same `X-Webhook-Timestamp` header and signature as the original, so deduplicate on the **signature header value** — unlike `event` + `timestamp`, it can't collide when two different records fire the same event in the same instant:

```javascript
const seen = new Set(); // use a database or cache with a TTL in production

function handleDelivery(req) {
  const key = req.get('X-Webhook-Signature'); // stable across retries, can't collide
  if (seen.has(key)) return; // already handled — a retry, skip it
  seen.add(key);

  processEvent(req.body); // ...actual processing
}
```

An in-memory set works for a single process; use your database or a shared cache once you run more than one instance.

## Step 6: Test locally

Webhook URLs must be reachable from the internet, but you can develop against your laptop with any tunneling tool (ngrok, Cloudflare Tunnel, or similar):

1. Start your receiver locally (port 3000 in the Express example).
2. Start a tunnel to that port — the tool gives you a public HTTPS URL.
3. Register that URL as a webhook (Step 2). You can register a second, temporary webhook just for development and delete it when you're done.
4. Trigger a real event — create a test lead in the app — and watch the delivery arrive in your terminal.

When you change tunnels (most tools issue a new URL per session), update the webhook with `PUT /v1/webhooks/:id` or in Settings → Webhooks.

## Step 7: Monitor deliveries

Two places to look when you think a delivery went missing:

- **In the app:** Settings → Webhooks shows delivery logs per webhook — what was sent, when, and whether your endpoint accepted it.
- **Via the API:**

```bash
curl "https://api.actuallycare.com/v1/webhooks/WEBHOOK_ID/logs" \
  -H "Authorization: Bearer $ACTUALLYCARE_JWT"
```

If deliveries are failing, the usual suspects in order: your endpoint isn't returning 200 within 30 seconds, the URL isn't publicly reachable (firewall, expired tunnel), or your signature check is rejecting valid payloads because it verified a re-serialized body instead of the raw bytes.

For a full integration that combines webhooks with the REST API, continue to [Build a custom integration](/guides/build-custom-integration).
