# Authentication and security

## API keys

Every API request is authenticated with an organization API key, sent as a bearer token:

```bash
curl -H "Authorization: Bearer cqa_1a2b3c4d_Zr0..." https://app.callqa.dev/api/v1/me
```

- Format: `cqa_<prefix>_<secret>`. The 8-character `prefix` identifies the key in lists and
  logs (`cqa_1a2b3c4d_...`). The secret is 43 random URL-safe characters.
- A key belongs to **one organization**, not to a person. It keeps working when the admin who
  created it leaves (except for invitations, see below).
- The full key is shown **once**, at creation. CallQA stores only a keyed hash (HMAC-SHA256
  with a server-side pepper): nobody, including CallQA staff, can read it back. Lost key: create
  a new one and revoke the old one.
- Keys can expire (`expires_in_days`, 1 to 3650). No expiry by default.

### Creating a key

In the dashboard, an admin opens **Developer > API keys** and clicks **+ API key**. Or, with a
key that has the `admin` scope:

```bash
curl -sS -X POST https://app.callqa.dev/api/v1/api-keys \
  -H "Authorization: Bearer $CALLQA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "GitHub Actions", "scopes": ["read", "run"], "expires_in_days": 180}'
```

```json
{
  "id": 12, "name": "GitHub Actions", "prefix": "9f8e7d6c", "scopes": ["read", "run"],
  "created_at": "2026-09-26T10:00:00Z", "expires_at": "2027-03-25T10:00:00Z",
  "last_used_at": null, "last_used_ip": null, "revoked_at": null, "allowed_cidrs": [],
  "rotated_from_id": null,
  "key": "cqa_9f8e7d6c_..."
}
```

Default scopes when you omit `scopes`: `read` and `run`.

## Scopes

A key can only do what its scopes allow. `admin` implies all the others.

| Scope | Allows |
|---|---|
| `read` | Read scenarios, runs, calls, recordings, traces, tags, versions, environments, usage, members; export replay bundles; read the API journal, webhook endpoints and their delivery logs |
| `write` | Create and edit scenarios, variants, numbers, tags, versions, environments; archive scenarios; sign a number's attestation |
| `run` | Trigger runs, cancel runs, replay calls; verify numbers (codes and verification calls) |
| `admin` | Billing, wallet and overage settings, members and invitations, API keys, webhook endpoints, alert channels, audit log, data exports, hard deletes (scenarios, numbers, versions, environments, runs and calls) |

A request without the needed scope gets `403`:

```json
{ "detail": "This API key lacks the 'run' scope (run.trigger).", "code": "missing_scope" }
```

The scope of every endpoint is listed in the [API reference](api-reference.md).

### Recommended scopes

| Use | Scopes |
|---|---|
| CI/CD pipeline that runs tests | `read`, `run` |
| Script that syncs scenarios from a repository | `read`, `write` |
| Dashboard or reporting integration | `read` |
| Infrastructure as code (webhooks, keys) | `admin`, kept in a vault |

## Roles

People in the dashboard have a role. API keys act at the organization level and are limited
only by their scopes; only admins can create keys.

| Action | admin | member | guest |
|---|---|---|---|
| View reports, runs, calls, scenarios | yes | yes | yes |
| Create and edit scenarios, numbers, tags, versions, environments | yes | yes | no |
| Trigger runs and replays, export bundles | yes | yes | no |
| Archive a scenario | yes | yes | no |
| Delete scenarios, numbers, variants, tags, versions, environments, runs and calls | yes | no | no |
| Verify a number, sign its attestation | yes | yes | no |
| Billing, wallet, members, API keys, webhooks, alerts, audit log | yes | no | no |
| View the API journal, webhook endpoints and their deliveries | yes | yes | no |

An organization can have several admins.

## Rotating a key

Rotate a key in place: CallQA issues a replacement with the same name, scopes, allowed networks
and expiry, and the old key keeps working for a grace period so you can deploy the new value
without downtime.

```bash
curl -sS -X POST https://app.callqa.dev/api/v1/api-keys/12/rotate \
  -H "Authorization: Bearer $CALLQA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"grace_period_hours": 24}'
```

The answer is `201` with the new key (in `key`, shown once) and `rotated_from_id`. The old key
now has an `expires_at` at most 24 hours away (`grace_period_hours`: 0 to 168, default 24;
`0` revokes it at once). In the dashboard: **Developer > API keys > Edit > Rotate key**.

1. Rotate (`POST /api-keys/{id}/rotate`).
2. Update the secret in your CI or vault and deploy.
3. Check that the old key is no longer used: `GET /api-keys` shows `last_used_at` per key
   (updated at most once per minute), and `GET /api-logs?key_prefix=<old prefix>` lists its
   latest requests.
4. Optional: revoke it before the end of the grace period with `DELETE /api-keys/{id}`
   (effective immediately, answers `204`).

A revoked or expired key gets `401`. Revoking is idempotent. Scopes never change on an
existing key: to change them, create a new key. The name and the allowed networks can change:
`PATCH /api-keys/{id}`.

## Restricting a key to IP addresses

A key can be limited to a list of networks (IPv4 or IPv6, CIDR notation or single addresses,
at most 20). A request from anywhere else gets `403` with `code: ip_not_allowed`, even with a
valid key. Use it for keys used from fixed egress addresses: self-hosted CI runners, a NAT
gateway, an office.

```bash
curl -sS -X PATCH https://app.callqa.dev/api/v1/api-keys/12 \
  -H "Authorization: Bearer $CALLQA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"allowed_cidrs": ["203.0.113.0/24", "2001:db8::/32"]}'
```

Send `"allowed_cidrs": []` to remove the restriction. Set it at creation with the same field
on `POST /api-keys`, or in the dashboard (**Developer > API keys > Edit**). Cloud CI runners (GitHub
hosted, GitLab SaaS) change addresses all the time: do not restrict their keys, or run your
own runners. The address checked is the one that reaches our load balancer; an
`X-Forwarded-For` header you send is ignored.

## IP addresses and the API journal

- Every authenticated request is recorded in the **API journal** with its method, path, status,
  duration, key prefix, client IP, user agent and request id. Bodies and headers are never
  stored. Read it with `GET /api-logs` (filters: `since`, `status_code`, `key_prefix`) or on
  the **Developer > API journal** page.
- Every `/api/v1` response carries an `X-Request-Id` header. Quote it when you contact support.
- Each key records `last_used_ip`, shown in the dashboard and in `GET /api-keys`.
- Brute force protection: more than 30 failed authentications (unknown, revoked or expired
  keys) from one IP address within a minute get `429` until the end of the minute. Valid keys
  from the same address are **not** blocked: they only count against their own rate limit.

## Audit log

Admins can read who did what in the organization: key creation, rotation and revocation,
webhooks, alert channels, members, billing settings, number verification and deletions. On the
**Organization > Audit log** page, or with an `admin` key:

```bash
curl -sS -H "Authorization: Bearer $CALLQA_ADMIN_KEY" \
  "https://app.callqa.dev/api/v1/audit-log?action=apikey.&since=2026-09-01T00:00:00Z"
```

`action` is an exact action (`webhook.create`) or a prefix ending with a dot (`apikey.`).
Other filters: `actor_api_key_prefix`, `since`, `until`, `limit`, `offset`. Entries never
contain secrets.

## Organization isolation

A key only sees its own organization. Objects of other organizations answer `404`, exactly
like objects that do not exist. A replay bundle can only call a number registered in your
organization. If your organization is suspended, its keys get `401`.

## Invitations sent with a key

`POST /invitations` sends the invitation on behalf of the admin who created the key. If that
person no longer exists, the request fails with `400`: create a new key.

## Webhook security

Outgoing webhooks are signed with HMAC-SHA256 and a per-endpoint secret. Webhook URLs must use
HTTPS and resolve to public IP addresses. See [Webhooks](webhooks.md).

## Single sign-on (SAML)

Scale plan. Your team signs in to the dashboard with your identity provider (Okta, Microsoft
Entra ID, Google Workspace, OneLogin, any SAML 2.0 IdP). API keys are not affected.

1. An admin opens **Organization > Single sign-on (SSO)**. It shows the values to give your IdP:
   entity id (audience) and assertion consumer service URL,
   `https://app.callqa.dev/accounts/saml/<your org slug>/acs/`. Send the email address as the
   NameID (format `emailAddress`) or as the `email` attribute, and sign the responses.
2. On the same page, enter the IdP entity id, single sign-on URL, signing certificate and your
   email domains, then **Request SSO setup**.
3. CallQA checks that your organization owns the domains and activates the connection, usually
   within one business day.

Then people sign in at `https://app.callqa.dev/sso/` with their work email. Only addresses of
your domains are accepted. Someone signing in for the first time becomes a member with the role
you chose (member by default); an existing account with the same email is connected. When no
seat is left in your plan (pending invitations count), that first sign-in is refused with a
page saying so: an admin frees a seat in **Organization > Members** or upgrades the plan, then
the person signs in again. Existing members always sign in. If the plan no longer includes
SSO, sign-ins through the IdP are refused and passwords keep working.

## Good practices

- One key per system (one for GitHub Actions, one for the reporting job): you can revoke one
  without breaking the others, and the journal tells you who did what.
- Give the smallest set of scopes. A CI key never needs `admin`.
- Store keys in your CI secret store (GitHub secrets, GitLab masked variables, Jenkins
  credentials), never in the repository or in scenario files.
- Set an expiry on keys given to contractors or temporary jobs.
- Never send a key to a browser. Call the API from a backend.
- Review the API journal after an incident and revoke unknown keys.
