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
403on every/v1/webhooksendpoint — ask your broker to set the webhook up, or to run these steps with you.Auth: the
/v1/webhooksendpoints accept a JWT bearer token only (fromPOST /v1/auth/login) — anX-API-Keyheader gets401here. 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#
- Open Settings → Webhooks (direct link:
https://app.actuallycare.com/settings?tab=webhooks). - 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:
| 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#
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", 200Step 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):
- Start your receiver locally (port 3000 in the Express example).
- Start a tunnel to that port — the tool gives you a public HTTPS URL.
- 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.
- 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.