> Source: https://wexa.ai/docs/api/api-keys

# API keys

Three routes manage `fab_sk_` credentials. All three require a **user token with an admin role on
the target project**, and none of them can be reached with an API key. This page is the per-route
reference; [authentication](/docs/api/authentication) explains where a key sits among the three
credential types.

| Route | Purpose | Credential |
|---|---|---|
| `POST /v1/apikeys` | Create a key and return its secret once | User token, admin role |
| `GET /v1/apikeys?project_id=…` | List the project's keys, without secrets | User token, admin role |
| `DELETE /v1/apikeys/{id}?project_id=…` | Revoke one key immediately | User token, admin role |

## Create a key

`POST /v1/apikeys` — body is JSON.

| Field | Required | Notes |
|---|---|---|
| `name` | **Yes** | A human label. Empty or missing is refused `400 name is required`. The id is random and carries no meaning, so this is the only way to tell keys apart later. |
| `org_id` | Yes in practice | Read from the **body**, not from your token's claims. Omitting it produces a key whose scope has no organization. |
| `dept_id` | No | Stamped into the key's scope as given. |
| `project_id` | Yes | The project the key is pinned to. |
| `grants` | No | An explicit grant list. Omitted, the key inherits the default set for the caller's role. |

```bash
curl -sS -X POST https://fabric.wexa.ai/v1/apikeys \
  -H "Authorization: Bearer $USER_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"nightly-report","org_id":"org_abc","dept_id":"dept_abc","project_id":"proj_abc"}'
```

`201 Created`:

```json
{
  "id": "key_IEhcUZrVvqI9C9hOAkwA_yOM",
  "name": "nightly-report",
  "note": "store this secret now; it is not retrievable again",
  "scope": {
    "org_id": "org_abc", "dept_id": "dept_abc", "project_id": "proj_abc",
    "user_id": "u_abc", "role": "OWNER",
    "grants": ["fabric:query.read", "fabric:docs.read", "fabric:ontology.write",
               "fabric:orchestrate.read", "fabric:orchestrate.write", "fabric:agent.run",
               "fabric:skill.write", "fabric:model.write"],
    "auth_kind": "api_key"
  },
  "secret": "fab_sk_EoWX5uQBDWVJfxXhgGdJOygW"
}
```

### The grants a new key gets

With no `grants` field the key inherits the **default set for the creating role**, not a fixed
starter pair:

- `OWNER`, `ORG_ADMIN`, `PROJECT_ADMIN` → eight grants, including `ontology.write`, `agent.run`,
  `skill.write` and `model.write`.
- Every other role → `query.read` and `docs.read` only. That branch is unreachable here in practice,
  because a non-admin cannot create a key at all.

An explicit list is honoured exactly. Creating a key with `{"grants":["fabric:query.read"]}` and
then reading `GET /v1/whoami` with it returned precisely one grant — and kept `"role": "OWNER"` in
the scope, because the role is copied from the creator while the grants are not. **Read the grants,
not the role.**

Narrow keys are the right default for anything unattended. A key cannot exceed what its creator
held, but it can very easily hold more than the job needs.

### Refusals

| Status | Body | Cause |
|---|---|---|
| `400` | `name is required` | `name` absent or blank. |
| `400` | `invalid_request` with a decoder message | The body is not valid JSON. |
| `401` | `invalid_token` | No bearer header, or a credential that is not a valid user token — including an API key. |
| `403` | `API key creation requires an admin role on this project` | A valid user token whose role is not `OWNER`, `ORG_ADMIN` or `PROJECT_ADMIN`. Confirmed live with a `PROJECT_MEMBER` token. |
| `502` | `identity_unavailable` | The token carried no role claim, so the gateway had to look up the caller's project role and could not. Membership is never taken on trust from the request. |

## List keys

`GET /v1/apikeys?project_id=…`. The query parameter is required — without it the call is refused
`400 project_id query param required` before authentication is even attempted.

```bash
curl -sS "https://fabric.wexa.ai/v1/apikeys?project_id=proj_abc" \
  -H "Authorization: Bearer $USER_TOKEN"
```

```json
{
  "keys": [
    {
      "id": "key_IEhcUZrVvqI9C9hOAkwA_yOM",
      "name": "nightly-report",
      "scope": { "org_id": "org_abc", "project_id": "proj_abc", "role": "OWNER", "grants": ["…"], "auth_kind": "api_key" },
      "created_by": "u_abc",
      "created_at": "2026-09-15T15:52:30.732832779+05:30",
      "last_used_at": "2026-09-15T15:52:41.582623887+05:30",
      "revoked": false
    }
  ]
}
```

`last_used_at` is the audit field worth watching. A key with no recent use is a key you can revoke
cheaply; a revoked key that keeps being used tells you which caller still has the old secret. The
secret itself is absent from every entry, and `revoked` keys stay listed rather than disappearing,
so the record of what existed survives the revocation.

## Revoke a key

`DELETE /v1/apikeys/{id}?project_id=…`. Both the path id and the query parameter are required.

```bash
curl -sS -X DELETE "https://fabric.wexa.ai/v1/apikeys/key_IEhcUZrVvqI9C9hOAkwA_yOM?project_id=proj_abc" \
  -H "Authorization: Bearer $USER_TOKEN"
```

```json
{ "ok": true }
```

The id is checked against the project's own key list before anything is revoked, so a project admin
cannot revoke a key belonging to another project by guessing its id. An id that is not in this
project answers `404 no such API key in this project` — the same answer whether the key exists
elsewhere or not at all.

Revocation takes effect on the next request. Immediately after the `{"ok":true}` above, the same
key on `GET /v1/whoami` answered:

```text
HTTP/1.1 401 Unauthorized

{"error":"invalid_token","error_description":"api key invalid or revoked"}
```

| Status | Body | Cause |
|---|---|---|
| `400` | `project_id query param required` | The query parameter is missing. |
| `401` | `invalid_token` | Not a user token, or not one at all. |
| `403` | `forbidden` | Valid user token, no admin role on that project. |
| `404` | `no such API key in this project` | The id is not in this project's list. |
| `502` | `revoke_failed` / `identity_unavailable` | The key store, or the record of the caller's role, could not be reached. |

## Rotating a key

There is no rotate endpoint. Rotation is three calls in this order, and the order is what keeps the
caller running:

1. `POST /v1/apikeys` — mint the replacement, with the **same grants** rather than the role default
   if the old key was deliberately narrow.
2. Deploy the new secret to every caller. Watch `last_used_at` on the old key stop moving.
3. `DELETE /v1/apikeys/{id}` — revoke the old one.

Reversing steps 1 and 3 takes the integration down for the length of the deployment.

## Audit

Creation and revocation both append an audit event — `apikey.created` and `apikey.revoked`, each
recording the actor, their role, the key id and the project. They are readable at `GET /v1/audit`;
see [lifecycle and audit](/docs/concepts/lifecycle-and-audit) for the record's shape and its
hash-chain verification.

## Related

  <Card title="Authentication" href="/docs/api/authentication">
    How a `fab_sk_` credential is resolved, and what limits apply to it.
  </Card>
  <Card title="Identity" href="/docs/api/identity">
    Reading back the scope and grants a key actually holds.
  </Card>
  <Card title="Roles and permissions" href="/docs/administration/roles-and-permissions">
    Which roles count as admin on a project.
  </Card>
  <Card title="Credential management" href="/docs/administration/credential-management">
    The same lifecycle from an administrator's point of view.
  </Card>
