Build a custom integration
An end-to-end tutorial: API key, client module, paginated lead sync, and a verified webhook receiver.
This tutorial builds a complete, production-shaped integration with a concrete goal: a nightly sync that pulls new leads out of ActuallyCare into your own system, and pushes status updates back. Along the way you'll add a webhook receiver so urgent events arrive in real time instead of waiting for the next sync run.
Everything here is copy-pasteable. You'll need:
- An ActuallyCare account with API access
- Node.js 18 or newer (which has
fetchbuilt in), or Python 3.9+ if you prefer the Python examples - About 30 minutes
Step 1: Get an API key#
All requests in this tutorial authenticate with an API key sent in the X-API-Key header.
- Open app.actuallycare.com and go to Settings → API Keys (direct link:
https://app.actuallycare.com/settings?tab=api). - Create a new key. Give it a name like
nightly-lead-sync, an expiration, and the scopes it needs — scopes are what grant a key access, and a key with no scopes gets403on every endpoint. For this tutorial,{"leads":["read","write"]}covers reading and updating leads (or{"all":["read","write"]}for broad access). - Copy the key immediately. It's a 64-character hex string, shown once at creation. After that, ActuallyCare stores only a hash and the UI shows just the first 8 and last 4 characters.
Every user can create their own keys — each key acts as you, with your role's visibility.
Store the key in an environment variable, never in code:
export ACTUALLYCARE_API_KEY="your-64-character-key"Sanity-check it with curl:
curl "https://api.actuallycare.com/v1/leads?limit=1" \
-H "X-API-Key: $ACTUALLYCARE_API_KEY"Every response uses the same envelope. On list endpoints data is an object keyed by the resource, with pagination metadata at data.meta. Success looks like this (record fields trimmed):
{
"success": true,
"data": {
"leads": [
{ "id": "a1b2c3d4-..." }
],
"meta": { "page": 1, "limit": 1, "total": 132, "totalPages": 132, "hasMore": true }
},
"timestamp": "2026-06-11T17:42:11.000Z"
}And errors look like this (an illustrative example):
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "name is required."
}
}Rate-limited 429s use the same envelope (error.code of RATE_LIMIT_EXCEEDED) and add a Retry-After header — branch on the HTTP status or the code, and honor the header. For more on keys, JWTs, and scopes, see Authentication.
Step 2: Write a small API client#
Wrap the envelope handling, error handling, and retry logic once, in one module, so the rest of your integration never thinks about it.
Node.js#
// actuallycare.js
const BASE_URL = 'https://api.actuallycare.com/v1';
const API_KEY = process.env.ACTUALLYCARE_API_KEY;
class ApiError extends Error {
constructor(status, code, message) {
super(`${code}: ${message}`);
this.status = status;
this.code = code;
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function request(method, path, { params, body } = {}) {
const url = new URL(BASE_URL + path);
for (const [key, value] of Object.entries(params || {})) {
url.searchParams.set(key, value);
}
// Retry on 429 and 5xx with exponential backoff: 1s, 2s, 4s.
for (let attempt = 0; ; attempt++) {
const res = await fetch(url, {
method,
headers: {
'X-API-Key': API_KEY,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
if ((res.status === 429 || res.status >= 500) && attempt < 3) {
await sleep(1000 * 2 ** attempt);
continue;
}
if (!res.ok) {
// Parse error bodies defensively — proxies can return non-JSON.
let err = {};
try { err = (await res.json()).error || {}; } catch { /* non-JSON body */ }
throw new ApiError(res.status, err.code || `HTTP_${res.status}`, err.message || 'Request failed');
}
const payload = await res.json();
return payload.data; // the envelope's data object, e.g. { leads: [...], meta: {...} }
}
}
module.exports = {
ApiError,
get: (path, params) => request('GET', path, { params }),
post: (path, body) => request('POST', path, { body }),
put: (path, body) => request('PUT', path, { body }),
};Python#
# actuallycare.py
import os
import time
import requests
BASE_URL = "https://api.actuallycare.com/v1"
API_KEY = os.environ["ACTUALLYCARE_API_KEY"]
class ApiError(Exception):
def __init__(self, status, code, message):
super().__init__(f"{code}: {message}")
self.status = status
self.code = code
def request(method, path, params=None, body=None):
headers = {"X-API-Key": API_KEY, "Content-Type": "application/json"}
# Retry on 429 and 5xx with exponential backoff: 1s, 2s, 4s.
for attempt in range(4):
res = requests.request(
method, BASE_URL + path,
headers=headers, params=params, json=body, timeout=30,
)
if (res.status_code == 429 or res.status_code >= 500) and attempt < 3:
time.sleep(2 ** attempt)
continue
if not res.ok:
# Parse error bodies defensively — proxies can return non-JSON.
try:
err = res.json().get("error", {})
except ValueError:
err = {}
raise ApiError(res.status_code, err.get("code", f"HTTP_{res.status_code}"),
err.get("message", res.text.strip() or "Request failed"))
# The envelope's data object, e.g. {"leads": [...], "meta": {...}}
return res.json()["data"]
def get(path, **params):
return request("GET", path, params=params)
def post(path, body):
return request("POST", path, body=body)
def put(path, body):
return request("PUT", path, body=body)Two things worth noting:
- The wrapper retries on 429 (rate limited) and 5xx automatically. The limit is 500 requests per 15 minutes per IP — see Rate limits for the response headers that tell you where you stand.
- Errors surface as a typed exception carrying the HTTP status and, when the body is the JSON envelope, its
error.code(a429carriesRATE_LIMIT_EXCEEDED). Branch onerr.statusorerr.code, never on messages; the defensive parse falls back toHTTP_<status>if a proxy returns a non-JSON body.
Step 3: Fetch leads with pagination#
List endpoints take page and limit query parameters (limit defaults to 25, max 100). The records come back under data, keyed by the resource — data.leads here — with pagination metadata at data.meta. Loop pages until meta.hasMore goes false:
// fetch-leads.js
const api = require('./actuallycare');
async function getAllLeads() {
const leads = [];
const limit = 100;
for (let page = 1; ; page++) {
const data = await api.get('/leads', { page, limit });
leads.push(...data.leads);
console.log(`Page ${page}: ${data.leads.length} leads (${leads.length} of ${data.meta.total})`);
if (!data.meta.hasMore) break;
}
return leads;
}
module.exports = { getAllLeads };The meta object also carries total and totalPages — see Pagination for the exact shape, and the leads reference for everything the endpoint accepts and returns.
Step 4: Create and update records, with real error handling#
The nightly sync has two write paths: creating a lead that originated in your system, and pushing a status change back to ActuallyCare.
The key habit: check before you create. The API doesn't know your external system's IDs, so dedupe on something stable like email before inserting — otherwise a re-run of a failed sync creates duplicates.
// sync.js
const fs = require('fs');
const api = require('./actuallycare');
const { getAllLeads } = require('./fetch-leads');
const STATE_FILE = './synced-lead-ids.json';
function loadState() {
try {
return new Set(JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')));
} catch {
return new Set();
}
}
async function nightlySync() {
const synced = loadState();
const leads = await getAllLeads();
// 1. Pull: hand any lead we haven't seen before to your own system.
for (const lead of leads) {
if (synced.has(lead.id)) continue;
await sendToYourSystem(lead); // your code here
synced.add(lead.id);
}
fs.writeFileSync(STATE_FILE, JSON.stringify([...synced]));
// 2. Push: send status changes from your system back to ActuallyCare.
for (const change of await getPendingStatusChanges()) { // your code here
try {
await api.put(`/leads/${change.leadId}`, { status: change.newStatus });
console.log(`Updated lead ${change.leadId} -> ${change.newStatus}`);
} catch (err) {
if (err.status === 429) {
// The client already retried; if it still failed, stop and resume next run.
console.error('Rate limited after retries, resuming next run.');
break;
}
// Validation and not-found errors: log the code and keep going.
console.error(`Lead ${change.leadId} failed: ${err.code} - ${err.message}`);
}
}
}
nightlySync().catch((err) => {
console.error('Sync failed:', err);
process.exit(1);
});Field names in request bodies (like status above) are illustrative — the generated endpoint reference is the source of truth for each resource's exact request and response shapes. Same goes for creating records: a POST /v1/leads with the fields from the leads reference returns the created record (including its UUID id) inside data.
Run the script from cron, a scheduled CI job, or whatever scheduler you already have. Once a night is plenty — the webhook receiver in the next step covers the in-between.
Step 5: Receive webhooks in real time#
Polling nightly means a hot lead could sit for hours. Webhooks fix that: ActuallyCare POSTs to your endpoint when events happen, signed so you can prove they're genuine.
Register a webhook first — the webhook setup guide covers discovery, registration, and the event catalog. Here's the receiving end in Express:
// webhook-server.js
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = process.env.ACTUALLYCARE_WEBHOOK_SECRET;
// Capture the raw body — the signature is computed over the exact bytes sent,
// so verify against those, not a re-serialized object.
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = buf; },
}));
function verifySignature(req) {
const received = req.get('X-Webhook-Signature') || '';
const expected = crypto
.createHmac('sha256', SECRET)
.update(req.rawBody)
.digest('hex'); // raw hex digest — no "sha256=" prefix
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');
}
// Respond immediately — deliveries time out after 30 seconds,
// and a timeout counts as a failure that triggers retries.
res.status(200).send('ok');
// Process asynchronously, after the response is sent.
setImmediate(() => handleEvent(req.body));
});
function handleEvent(payload) {
// Payload envelope: { "event": "...", "team_id": "...", "timestamp": "...", "data": {...} }
// Use exact event names from GET /v1/webhooks/events.
if (payload.event.startsWith('lead.')) {
console.log(`Lead event ${payload.event} at ${payload.timestamp}`);
// e.g. push payload.data into your system right away
}
}
app.listen(3000, () => console.log('Listening on :3000'));Three rules keep webhook handling boring (in the good way):
- Verify every delivery. HMAC-SHA256 hex of the raw JSON body using your webhook secret, compared timing-safe against
X-Webhook-Signature. - Return 200 fast, work later. Deliveries time out after 30 seconds. Failures (network errors, or
5xx/408/429responses) are retried at 1, 5, and 15 minutes, then given up — other4xxresponses are not retried, so don't reject events you just haven't handled yet. - Be idempotent. Delivery is at-least-once — the same event can arrive more than once. Dedupe on the
eventplustimestamppair before acting.
More background on the event catalog and delivery semantics lives in Webhooks.
Step 6: Production checklist#
Before you point this at real data:
- Keys in environment variables. Never commit
ACTUALLYCARE_API_KEYorACTUALLYCARE_WEBHOOK_SECRET. Use your platform's secret store in production. - Backoff on 429 and 5xx. The client module above retries with exponential backoff; keep that behavior if you replace it. Budget your request rate against the 500-per-15-minutes limit — see Rate limits.
- Idempotency everywhere. Check before you create (Step 4), dedupe webhook deliveries (Step 5), and persist sync state so a crashed run resumes instead of re-importing.
- Log requests and error codes. When something fails at 2 a.m., the envelope's
error.codeplus the request you sent is usually all you need to diagnose it. The errors page explains the envelope. - Set key expirations and rotate. Expiration is optional at creation, and a key without one never expires — so set one explicitly. Treat a leaked key like a leaked password: delete it in Settings → API Keys and issue a new one.
- Watch your webhook logs. Delivery logs are visible in the app under Settings → Webhooks, and via
GET /v1/webhooks/:id/logs.
From here, the endpoint reference documents every resource this tutorial touched, and the API vs MCP page explains when a Claude-driven MCP integration is a better fit than REST.