Skip to content
For developers
View as Markdown

Set up webhooks

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

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.

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) — 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:

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:

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#

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:

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

And these headers:

HeaderContents
X-Webhook-Signaturesha256= followed by the HMAC-SHA256 hex digest of the JSON body, keyed with your secret (e.g. sha256=3f5a…).
X-Webhook-EventThe event name, so you can route before parsing the body.
X-Webhook-TimestampWhen 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#

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#

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:

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