# Build a custom integration

> An end-to-end tutorial: API key, client module, paginated lead sync, and a verified webhook receiver.

<!-- Source: https://docs.actuallycare.com/guides/build-custom-integration -->

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](https://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:

```bash
export ACTUALLYCARE_API_KEY="your-64-character-key"
```

Sanity-check it with curl:

```bash
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):

```json
{
  "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):

```json
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "name is required."
  }
}
```

Rate-limited `429`s 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](/api/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

```javascript
// 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

```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](/api/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`:

```javascript
// 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](/api/pagination) for the exact shape, and [the leads reference](/api/reference/leads) 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.

```javascript
// 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](/api/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](/api/reference/leads) 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](/guides/webhook-setup) covers discovery, registration, and the event catalog. Here's the receiving end in Express:

```javascript
// 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](/concepts/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](/api/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](/api/errors) 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](/api/reference) documents every resource this tutorial touched, and the [API vs MCP](/concepts/api-vs-mcp) page explains when a Claude-driven MCP integration is a better fit than REST.
