Webhooks
Event-driven integration: catalog, signatures, retries.
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.
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):
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:
{
"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:
{
"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:
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, or429response; other4xxresponses 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/webhooksto list and create,PUT/DELETE /v1/webhooks/:idto update and remove,GET /v1/webhooks/:id/logsfor delivery history.
Ready to wire one up end to end? Follow the webhook setup guide.