Skip to content
For developers
View as Markdown

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 fetch built 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.

  1. Open app.actuallycare.com and go to Settings → API Keys (direct link: https://app.actuallycare.com/settings?tab=api).
  2. 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 gets 403 on every endpoint. For this tutorial, {"leads":["read","write"]} covers reading and updating leads (or {"all":["read","write"]} for broad access).
  3. 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 (a 429 carries RATE_LIMIT_EXCEEDED). Branch on err.status or err.code, never on messages; the defensive parse falls back to HTTP_<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):

  1. Verify every delivery. HMAC-SHA256 hex of the raw JSON body using your webhook secret, compared timing-safe against X-Webhook-Signature.
  2. Return 200 fast, work later. Deliveries time out after 30 seconds. Failures (network errors, or 5xx/408/429 responses) are retried at 1, 5, and 15 minutes, then given up — other 4xx responses are not retried, so don't reject events you just haven't handled yet.
  3. Be idempotent. Delivery is at-least-once — the same event can arrive more than once. Dedupe on the event plus timestamp pair 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_KEY or ACTUALLYCARE_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.code plus 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.