# Authentication

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

<!-- Source: https://docs.actuallycare.com/api/authentication -->

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

```bash
curl "https://api.actuallycare.com/v1/clients" \
  -H "X-API-Key: YOUR_API_KEY"
```

```json
{
  "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](#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](/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:

```text
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](https://app.actuallycare.com) and open **Settings → API Keys** ([app.actuallycare.com/settings?tab=api](https://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](#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](#jwt-bearer-tokens) below), not with an API key:

```bash
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"] } }'
```

```json
{
  "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](/api/reference/api-keys).

### 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`:

```bash
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](/api/reference/api-keys)).

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.

```bash
export ACTUALLYCARE_API_KEY="9f2c4e8a1b3d5f7092c4e6a8b0d2f4169e8c0a2b4d6f8e1a3c5b7d9f0e2a4c6b"
```

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

```bash
curl -X POST "https://api.actuallycare.com/v1/auth/login" \
  -H "Content-Type: application/json" \
  -d '{ "email": "agent@example.com", "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").

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

```bash
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](/mcp/custom-apps).

## 401 vs 403

| Status | What it means | Typical causes |
| --- | --- | --- |
| `401` | The request wasn't authenticated | Missing `X-API-Key` or `Authorization` header, a typo'd or revoked key, an expired key, an expired JWT |
| `403` | Authenticated, but not allowed | The 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](/api/errors).
