Skip to content
For developers
View as Markdown

Authentication

API keys and JWT bearer tokens for the ActuallyCare REST API, plus how 401 differs from 403.

Most integrations authenticate with an API key in the X-API-Key header:

curl "https://api.actuallycare.com/v1/clients" \
  -H "X-API-Key: YOUR_API_KEY"
{
  "success": true,
  "data": {
    "clients": [],
    "meta": { "page": 1, "limit": 25, "total": 0, "totalPages": 0, "hasMore": false }
  },
  "timestamp": "2026-06-11T18:24:09.123Z"
}

A 200 with the standard envelope means your key works and has the scope this endpoint needs — a key with no scopes gets 403 on every endpoint, so scopes are part of "working" (see Scopes below). The rest of this page covers API keys in depth, then JWT auth for apps where users sign in directly.

A few endpoint groups take only a JWT bearer token — an X-API-Key header gets 401 No authentication token provided there: key management (/v1/api-keys), webhooks (/v1/webhooks), contacts (/v1/contacts), and billing (/v1/billing). Each page in the API reference shows the auth its endpoints actually accept.

API keys#

The header#

Send the key on every request in the X-API-Key header. The header name API-Key also works, but X-API-Key is the documented form — use it in new code.

Key format#

Keys are 64-character hex strings with no prefix:

9f2c4e8a1b3d5f7092c4e6a8b0d2f4169e8c0a2b4d6f8e1a3c5b7d9f0e2a4c6b

The full key is shown exactly once, at creation. ActuallyCare stores only a hash, so it can never be retrieved again — if you lose it, create a new key. After creation the UI shows just the first 8 and last 4 characters so you can tell keys apart.

Create a key in the app#

  1. Sign in at app.actuallycare.com and open Settings → API Keys (app.actuallycare.com/settings?tab=api). Every user can create their own keys — each key acts as you, with your role's visibility.
  2. Name the key after the integration it serves, pick an expiration (the UI also allows 0, meaning no expiration), and create it.
  3. Copy the key before closing the dialog.

Make sure the key is granted the scopes it needs — a key with no scopes can't access anything (see Scopes).

Create a key over the API#

POST /v1/api-keys creates a key programmatically. Key management authenticates with a JWT bearer token (sign in first — see JWT bearer tokens below), not with an API key:

curl -X POST "https://api.actuallycare.com/v1/api-keys" \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Reporting integration", "expiresInDays": 90, "scopes": { "all": ["read", "write"] } }'
{
  "success": true,
  "data": {
    "id": "5c2e7f1a-8b4d-4e6f-9a0c-1d3e5f7a9b2c",
    "key": "9f2c4e8a1b3d5f7092c4e6a8b0d2f4169e8c0a2b4d6f8e1a3c5b7d9f0e2a4c6b",
    "name": "Reporting integration",
    "expires_at": "2026-09-09T18:24:09.123Z"
  },
  "timestamp": "2026-06-11T18:24:09.123Z"
}

name is required. expiresInDays is optional — 1 to 365 days. If you omit it, the key never expires — there's no implicit default, so set an expiry explicitly. scopes is what makes the key usable at all (next section). The data.key field in the response is the only time you'll see the full key. The full set of key-management endpoints (list, revoke, delete) is in the API Keys reference.

Scopes#

Scopes grant access — a key has no permissions except the ones its scopes spell out. A key created without a scopes object has an empty scope set and gets 403 ("API key missing required scope") on every documented endpoint. Always include scopes when you create a key:

  • {"all":["read"]} — read access everywhere the key's user can see
  • {"all":["read","write"]} — full read and write access
  • {"clients":["read","write"]} — per-resource grants; this key can only touch clients

Grants combine, so {"all":["read"],"clients":["read","write"]} means read everywhere plus write on clients. You can also grant or change scopes after creation with PATCH /v1/api-keys/:id/scopes:

curl -X PATCH "https://api.actuallycare.com/v1/api-keys/KEY_ID/scopes" \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '{ "scopes": { "all": ["read"], "clients": ["read", "write"] } }'

Give each key the narrowest grant that still works. Scopes are an API-key concept — JWT-authenticated users aren't scope-checked; they act with their normal role and permissions.

Rotate keys#

Rotation is a three-step swap, with no downtime:

  1. Create a new key.
  2. Update the integration to use it and confirm it works.
  3. Revoke the old key (in the UI, or via the API Keys endpoints).

Use one key per integration so you can rotate or revoke each one independently, and revoke immediately if a key ever leaks. A revoked key fails with 401 on the next request.

Store keys safely#

  • Keep keys in environment variables or a secrets manager — never in source code, and never committed to git.
  • Never ship a key in client-side code (browser bundles, mobile apps). Anything a user can download can be read.
  • Don't log keys, and don't paste them into chat tools or tickets.
export ACTUALLYCARE_API_KEY="9f2c4e8a1b3d5f7092c4e6a8b0d2f4169e8c0a2b4d6f8e1a3c5b7d9f0e2a4c6b"
const res = await fetch("https://api.actuallycare.com/v1/clients", {
  headers: { "X-API-Key": process.env.ACTUALLYCARE_API_KEY },
});

JWT bearer tokens#

If you're building an app where ActuallyCare users sign in with their own email and password, use JWT auth instead of a shared API key — each user acts with their own permissions.

Log in#

curl -X POST "https://api.actuallycare.com/v1/auth/login" \
  -H "Content-Type: application/json" \
  -d '{ "email": "[email protected]", "password": "their-password" }'

The email field also accepts a username — { "username": "agent1", "password": "..." } works the same way (that's why a failed login says "Invalid username or password").

{
  "success": true,
  "data": {
    "user": { "id": "ab12cd34-..." },
    "accessToken": "eyJhbGciOiJIUzI1NiIs...",
    "refreshToken": "f3a1c9e7...",
    "expiresIn": "15m",
    "tokenType": "Bearer"
  },
  "timestamp": "2026-06-11T18:24:09.123Z"
}

Send the access token on requests as a bearer token:

curl "https://api.actuallycare.com/v1/clients" \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."

Other login outcomes#

A 200 from /auth/login isn't always a ready-to-use session — branch on the response shape:

  • Two-factor enabled: the response carries data.requiresTOTP: true (or data.requiresSMS: true) plus data.userId, and no token. Complete the challenge on the corresponding verification endpoint before you get a JWT.
  • Waitlisted account: newly registered accounts start on a waitlist. Login succeeds with data.status: "waitlisted" and a token, but that token can only call /auth/verify — every data and API-key endpoint returns 403 ACCOUNT_NOT_ACTIVE until the account is approved. The response's data.redirect points at the waitlist page.
  • Account locked: five consecutive failed logins lock the account for 30 minutes and send an alert email to the account owner. While locked, login returns HTTP 423 with the code ACCOUNT_LOCKED and error.details.locked_until. Stop retrying and wait it out — more attempts extend nothing but the frustration.

Token lifetimes and refresh#

Access-token lifetime is role-based — anywhere from 15 minutes to 8 hours depending on the user's role. Don't hardcode an expiry, and note that expiresIn is a duration string like "15m", "4h", or "8h" — not a number of seconds. Parse the string, or decode the JWT's exp claim, and refresh before the token runs out.

To refresh, call POST /v1/auth/refresh with the refresh token. Each refresh rotates the refresh token — store the new one and discard the old. Sessions stay alive on a 30-day sliding window, with a 90-day absolute cap; after that the user signs in again.

One token, both servers#

The same JWT also authenticates against the MCP server at https://mcp.actuallycare.com/mcp, so an app that manages user sessions can call REST endpoints and MCP tools with a single credential. See Build a custom MCP app.

401 vs 403#

StatusWhat it meansTypical causes
401The request wasn't authenticatedMissing X-API-Key or Authorization header, a typo'd or revoked key, an expired key, an expired JWT
403Authenticated, but not allowedThe endpoint is role-gated, the record belongs to another team, or the key is missing the required scope (a key with no scopes fails everywhere)

The practical rule: a 401 means fix your credential; a 403 means fix your permissions — retrying won't help either one. Both come back in the standard error envelope described in Errors.