# API key management

Use this API family to create and manage API keys for a consumer account or an
organization. The management API controls ownership, lifecycle, descriptive
fields, and optional restrictions. A generated key is returned only once.

API keys do not authenticate this management API. Call these routes with an
M7 SSO access token accepted by the
[API User authorization profile](https://m7.org/docs/api/api.user.m7.org/authorization.md). To validate a complete
API key in a receiving service, use the
[SSO API-key verification endpoint](https://m7.org/docs/api/sso.user.m7.org/api-key-verification).

## Access, ownership, and identifiers

Personal keys belong to the signed-in consumer user. Organization keys belong
to an active organization where the signed-in user has current Management-group
access. Organization-member tokens do not manage keys through this family.

The service fixes ownership at creation:

- `uid` identifies the authenticated issuer that created the row.
- `tenant` is the nil UUID for a personal key or the selected organization UUID.
- `issued_for` and `recipient_context` are immutable attribution and scoping
  references. They do not change the owner, tenant, or authorization principal.
- `id` is the API-key record UUID used by management routes. It is separate
  from the generated `api_key` credential.

Omit `tenant`, send `null`, or send the nil UUID for the personal context. Send
an organization UUID only when the caller currently manages that active,
unarchived organization. Invalid or inaccessible organization contexts fail;
they do not fall back to personal ownership.

Use `/api_keys/tenants` to list the caller's available personal and active
organization contexts. Use `/api_keys/tenant-info` with optional `tenant` to
inspect and validate one context.

## Authentication scopes

Every route uses the API User Bearer-token authorization profile. Caller
applications subject to route-scope checks need the configured scope below.
The service applies its existing dot-delimited prefix comparison; these names
do not introduce a wildcard grammar.

| Route | Configured scope |
| --- | --- |
| `/api_keys` | `api_keys` |
| `/api_keys/insert` | `api_keys.insert` |
| `/api_keys/view` | `api_keys.view` |
| `/api_keys/search` | `api_keys.search` |
| `/api_keys/update` | `api_keys.update` |
| `/api_keys/update_status` | `api_keys.update_status` |
| `/api_keys/set_knobs` | `api_keys.set_knobs` |
| `/api_keys/set_tags` | `api_keys.set_tags` |
| `/api_keys/tags/search` | `api_keys.tags.search` |
| `/api_keys/tags/insert` | `api_keys.tags.insert` |
| `/api_keys/tags/update` | `api_keys.tags.update` |
| `/api_keys/tags/delete` | `api_keys.tags.delete` |
| `/api_keys/revoke` | `api_keys.revoke` |
| `/api_keys/delete` | `api_keys.delete` |
| `/api_keys/rotate` | `api_keys.rotate` |
| `/api_keys/tenants` | `api_keys.tenants` |
| `/api_keys/tenant-info` | `api_keys.tenant_info` |

All routes in this guide use `POST` under
`https://api.user.m7.org/api/v2`.

## Lifecycle

A safe setup sequence is:

1. Create a disabled key with `/api_keys/insert`.
2. Store the returned `api_key` immediately; it cannot be retrieved later.
3. Configure expiry, a use allowance, IP entries, or metadata with
   `/api_keys/set_knobs` when needed.
4. Activate the key with `/api_keys/update_status`.
5. Pause it by setting `status` to `disabled`, or permanently end it with
   `/api_keys/revoke`.
6. Permanently remove a revoked record with `/api_keys/delete` when its history
   is no longer needed.

The stored lifecycle states are `active`, `disabled`, and `revoked`. An active
key whose use allowance is exhausted is displayed as `exhausted` by search,
but its stored status remains `active`. Revocation is terminal: a revoked key
cannot be reactivated, edited, rotated, or configured. Deletion accepts only an
already-revoked row.

## Routes

| Route | Required fields | Purpose |
| --- | --- | --- |
| `/api_keys/insert` | `name` | Creates a disabled key and returns the credential once. |
| `/api_keys/view` | `id` | Returns one accessible, non-secret management record. |
| `/api_keys/search` | None | Lists keys in the selected tenant context. |
| `/api_keys/update` | `id` | Partially updates main descriptive fields and optional audience/scope lists. |
| `/api_keys/update_status` | `id`, `status` | Sets `active` or `disabled`; cannot revive a revoked key. |
| `/api_keys/set_knobs` | `id` plus at least one knob | Partially updates typed optional controls. |
| `/api_keys/set_tags` | `id`, `tag_ids` | Replaces the key's tag assignments; `[]` clears them. |
| `/api_keys/tags/search` | `id` | Lists the API-key tag pool derived from an accessible key. |
| `/api_keys/tags/insert` | `id`, `name` | Creates a tag in that pool; optional `slug` and `description`. |
| `/api_keys/tags/update` | `id`, `tag_id` | Updates supplied `name`, `slug`, or `description` fields. |
| `/api_keys/tags/delete` | `id`, `tag_id` | Deletes an unassigned tag from that pool. |
| `/api_keys/revoke` | `id` | Terminally revokes a key; optional `reason` records why. |
| `/api_keys/delete` | `id` | Permanently deletes an already-revoked row and its child settings. |
| `/api_keys/rotate` | `id` | Atomically revokes the old key and returns one replacement credential. |
| `/api_keys/tenants` | None | Lists accessible personal and active organization contexts. |
| `/api_keys/tenant-info` | None | Returns the personal context or the selected active organization context. |

## Create a key

`insert` accepts only creation fields. The service generates the record ID,
credential selector, secret, secret hash, status, counters, and audit fields.
Callers cannot supply them.

| Field | Required | Type | Behavior |
| --- | --- | --- | --- |
| `name` | Yes | String | Human-readable name, up to 191 characters. |
| `notes` | No | String or `null` | Description for people managing the key. |
| `tenant` | No | UUID | Omit for personal ownership; an organization must be active and manageable by the caller. |
| `issued_for` | No | String | Opaque attribution reference; defaults to the issuer's `uid`. |
| `recipient_context` | No | String | Opaque context reference; defaults to `platform`. |
| `allowed_audiences` | No | Array of strings | Optional audience entries. Omission or `[]` creates no configured audience restriction. |
| `allowed_scopes` | No | Array of strings | Optional scope entries. Omission or `[]` creates no configured scope restriction. |

Audience and scope lists are independent and elective. Supplying one never
requires the other. An empty list means that restriction is unconfigured; it
does not grant authority beyond the key's owner and tenant. Receiving services
decide how to apply configured restrictions. They may also ask SSO verification
to require a personal `owner` or organization `tenant`; that check
does not change ownership or management access. Each audience entry may contain
up to 255 characters. Each scope entry may contain up to 191 characters and is
normalized to lowercase. Duplicate entries are removed case-insensitively.

```bash
curl --request POST \
  'https://api.user.m7.org/api/v2/api_keys/insert' \
  --header 'Authorization: Bearer ACCESS_TOKEN' \
  --header 'Content-Type: application/json' \
  --data '{
    "name": "Example integration",
    "notes": "Used by the reporting worker",
    "allowed_audiences": ["https://api.example.m7.org"],
    "allowed_scopes": []
  }'
```

The response uses `Cache-Control: no-store`. Its `data.record` is the disabled
management row. `data.api_key` is the only usable credential copy:

```json
{
  "status": 1,
  "comment": "INSERT",
  "data": {
    "record": {
      "id": "API_KEY_RECORD_UUID",
      "name": "Example integration",
      "status": "disabled",
      "tenant": "00000000-0000-0000-0000-000000000000",
      "allowed_audiences": ["https://api.example.m7.org"],
      "allowed_scopes": [],
      "uses_used": 0
    },
    "api_key": "m7ak_1_<selector>_<secret>"
  }
}
```

Store the credential in a secret manager before leaving the response. Never
put it in a URL, source file, log, analytics event, or support message.

## View and search

`view` takes the management row `id` and returns an accessible record without
the generated credential or its stored hash. Management records include owner
and tenant context, attribution, status, expiry, use counters, revocation and
rotation references, independent audience/scope/IP lists, typed `knobs`, assigned
`tags`, and timestamps.

`search` defaults to the personal tenant. Send `tenant` for an accessible
organization. Ownership and tenant constraints are applied before filtering,
counting, and pagination.

Useful search fields include:

| Field | Behavior |
| --- | --- |
| `q` | Case-insensitive substring search over visible identity, description, attribution, audience, scope, and IP fields. Metadata is excluded. |
| `display_status` | `all`, `active`, `exhausted`, `disabled`, or `revoked`. |
| `status` | Stored status filter: `active`, `disabled`, or `revoked`. |
| `id`, `name`, `issued_for`, `recipient_context`, `expires`, `rotated_from` | Exact stored-field filters. |
| `tag_id` | Includes only keys assigned to this tag in the selected context. |
| `include_tag_facets` | With pagination and `true`, adds `tag_facets` containing in-use tags and `usage_count`. |
| `limit`, `page_number`, `offset`, `cursor` | Shared pagination controls. |

When any pagination field is present, API-key search uses a default and maximum
`limit` of 50 and returns:

```json
{
  "status": 1,
  "comment": "OK",
  "data": {
    "items": [],
    "limit": 50,
    "total_count": 0,
    "next_offset": 50,
    "page_number": 1,
    "page_total": 0,
    "next_cursor": null
  }
}
```

Tag counts are calculated across accessible keys matching the other supplied
filters, including text and status, before applying the selected tag and page
limit. The selected tag is then applied before pagination totals. A missing or
out-of-context tag matches no keys. Counts never include another owner or
organization.

Without pagination fields, `data` is the legacy raw array. Treat
`next_cursor` as opaque and send it unchanged on the next request.

## Organize keys with tags

API-key tags use the same name, slug, and optional description model as
application-registration tags, with a separate tag pool. A personal API-key
pool belongs to the user; an organization API-key pool belongs to that
organization and is shared by its authorized managers. OAuth application tags
and API-key tags are separate.

Every pool route takes an accessible API-key record `id`. The server derives
the pool from that record; callers cannot override its owner or organization.
Pool mutations and assignment require a nonrevoked key. Revoked keys keep their
assigned tags for viewing and filtering, but cannot be used to modify them.

Create a tag with `/api_keys/tags/insert`:

```json
{
  "id": "API_KEY_RECORD_UUID",
  "name": "Reporting",
  "slug": "reporting",
  "description": "Keys used by reporting jobs"
}
```

Omitting `slug` derives it from the name. Slugs must be unique within the pool.
Insert and update return the tag record as `data`. Updating a shared tag changes
its label wherever it is assigned in that pool; it does not move the tag into
a different pool.

Assign tags with `/api_keys/set_tags`:

```json
{
  "id": "API_KEY_RECORD_UUID",
  "tag_ids": ["TAG_RECORD_UUID"]
}
```

This replaces the complete assignment list. Send `[]` to remove all assignments;
omitting `tag_ids` is an error. Every supplied ID must belong to the same pool,
and duplicate IDs are rejected. The response `data` is the updated management
record, including its `tags` array.

Pool search accepts optional exact `tag_id`, `slug`, and `name` filters and the
shared offset/cursor pagination inputs. Its default limit is 50 and maximum is
100. Its `data` contains `scope`, `tags`, `limit`, `offset`, `has_more`,
`next_offset`, and `next_cursor`; continue until `has_more` is false.

Pool deletion fails while any key still uses the tag. Remove assignments before
deleting it. Rotation copies assignments to the replacement in the same
transaction and preserves the predecessor's tags; permanent key deletion clears
that key's assignments. A failed tag copy rolls back the rotation.

Tags are management metadata. They grant no permissions, are not verifier
constraints, and are not included in the key-only verification profile. Use
optional audience/scope expectations when the receiving service needs those
checks.

## Update main fields

Use `/api_keys/update` for `name`, `notes`, `allowed_audiences`, and
`allowed_scopes`. This is a partial update: omitted fields stay unchanged.
Sending `[]` explicitly clears the corresponding audience or scope list.

The route cannot change `id`, `uid`, `tenant`, `issued_for`,
`recipient_context`, credential material, lifecycle audit fields, use counters,
or rotation lineage. Use the typed routes for lifecycle and knobs; arbitrary
configuration names are not accepted through the public API.

```bash
curl --request POST \
  'https://api.user.m7.org/api/v2/api_keys/update' \
  --header 'Authorization: Bearer ACCESS_TOKEN' \
  --header 'Content-Type: application/json' \
  --data '{
    "id": "API_KEY_RECORD_UUID",
    "name": "Reporting integration",
    "allowed_scopes": ["reports.read"]
  }'
```

## Configure typed knobs

`/api_keys/set_knobs` accepts only these fields. Omitted knobs remain
unchanged, so send only the settings being changed.

| Field | Type | Behavior |
| --- | --- | --- |
| `expires` | Absolute date-time or `null` | `null` means Never. ISO 8601 with an offset is accepted; `YYYY-MM-DD HH:MM:SS` is interpreted as UTC. |
| `use_limit` | Positive integer or `null` | `null` means Unlimited. Zero and negative values are rejected. |
| `ip_whitelist` | Array of IP/CIDR strings | `[]` clears the list. Bare IPv4/IPv6 addresses are stored as `/32` or `/128`. |
| `metadata` | JSON object or `null` | Customer metadata; `null` clears it. Metadata is descriptive and grants no authority. |

`expires`, `use_limit`, audience, scope, and IP entries are optional configured
restrictions. The key-only verifier always enforces a configured IP whitelist
against its direct connection address. A receiving service can also supply one
expected audience and a whitespace-delimited set of required scopes. Audience
matching is exact and case-sensitive; requested scope tokens are normalized to
lowercase and must all exactly match configured scopes. There is no wildcard or
prefix grant. Omitting audience or scope skips that check, while supplying a
check against an empty stored list fails.

Verification accepts separate optional ownership expectations. `owner` accepts
a consumer UUID or canonical M7 username, or an any-match list of those
values, and matches personal keys only. Every organization key fails a supplied
`owner` check, even when it contains the organization UUID or issuing user's
identity. `tenant` accepts a non-nil organization UUID or an any-match list of
such UUIDs and matches organization keys only. Organization `uid` remains
issuer attribution. Neither check changes ownership or management access.

Both expectations accept JSON scalar/list input and form scalar or repeated
unindexed list input (`owner[]` or `tenant[]`). Each supplied list is limited to
32 entries before deduplication. Owner entries are limited to 255 trimmed
bytes; tenant entries must be dashed non-nil UUIDs. If both expectations are
supplied, both must pass, so the result is invalid for either key kind. Omitting
both preserves key-only verification. A mismatch consumes no use and discloses
no profile. See the
[SSO verification contract](https://m7.org/docs/api/sso.user.m7.org/api-key-verification)
for the complete input and error rules.

Each successful verification increments `uses_used`, including for an
unlimited key, and sets `last_used_at` in UTC. Failed credential, lifecycle,
IP, audience, or scope checks do not update either field.

```bash
curl --request POST \
  'https://api.user.m7.org/api/v2/api_keys/set_knobs' \
  --header 'Authorization: Bearer ACCESS_TOKEN' \
  --header 'Content-Type: application/json' \
  --data '{
    "id": "API_KEY_RECORD_UUID",
    "expires": "2030-01-15T12:00:00Z",
    "use_limit": 5000,
    "ip_whitelist": ["192.0.2.40", "2001:db8::/48"],
    "metadata": {"environment": "example"}
  }'
```

## Activate, pause, revoke, and delete

Activate or pause a nonrevoked key with `/api_keys/update_status`:

```json
{
  "id": "API_KEY_RECORD_UUID",
  "status": "active"
}
```

Only `active` and `disabled` are accepted by this route. Activation of an
organization key revalidates that the organization is active and manageable.

Revoke permanently with an optional reason:

```json
{
  "id": "API_KEY_RECORD_UUID",
  "reason": "Credential was replaced"
}
```

After revocation, `/api_keys/delete` with the same `id` permanently removes the
row and its audience, scope, IP, metadata, and tag assignments. Shared tag
definitions remain in their pool. Active and disabled
keys must be revoked before deletion.

## Rotate a key

`/api_keys/rotate` takes the current row `id`. Rotation is an atomic
replacement rather than an overlap period:

- it creates a new row with a new ID and new one-time credential;
- it preserves owner, tenant, attribution, active/disabled status, expiry,
  remaining use state, IP entries, audience/scope lists, metadata, and tag
  assignments;
- it records `rotated_from` on the replacement; and
- it terminally revokes the predecessor in the same operation.

The response uses `Cache-Control: no-store` and has the same
`data.record`/`data.api_key` shape as insertion. Store the replacement before
discarding the response. Rotation cannot revive or rotate an already-revoked
row.

## Errors

Failures use the [standard API User response envelope](https://m7.org/docs/api/api.user.m7.org/README.md#request-and-response-format).
Handle its application `status` and the HTTP status separately; treat
`comment` as diagnostic prose rather than a machine protocol.

Requests fail when required fields are missing or malformed, list or typed-knob
values fail validation, or the selected organization is invalid, inactive, or
outside the caller's current management access. A record ID owned by another
personal user or inaccessible organization is not exposed. Revoked rows reject
editing, status changes, knob changes, and rotation, while active or disabled
rows reject permanent deletion. Re-read the accessible record before deciding
whether to retry a lifecycle action.

## Verification boundary

The receiving service can submit the complete API key to
`POST https://sso.user.m7.org/api-key/verify`. The complete key is its only
credential; no API.User management access token or second credential is
required. Optional `owner` and `tenant` expectations pin personal and organization
keys respectively.
Unknown, wrong-secret, disabled, revoked, expired, or exhausted keys return
one uniform invalid result; a valid key returns a safe allowlisted dataset.
Malformed requests return HTTP 400.

Successful verification atomically consumes one use, even when `use_limit` is
Unlimited, records `last_used_at` in UTC, and returns the updated count and
time. The final available use succeeds; the next request returns
`valid: false`. A configured IP whitelist is enforced against the verifier's
direct connection address. Optional supplied audience and scope expectations
are enforced as described above. `owner` applies only to personal keys; any
organization key fails it. Use `tenant` to require a particular organization
UUID. Each supports an any-match list. These checks do not reinterpret
`issued_for` or the issuer. Failed checks do not consume a use.

The verification transaction commits before the caller receives the HTTP
response. A lost response or later resource-processing failure does not refund
the use, so do not call verification repeatedly for one resource request.
Verification still does not assert that the owner currently has permission to
perform the receiving service's operation. Use the returned owner context and
policy as inputs to that service's own documented authorization decision. See
the
[SSO verification contract](https://m7.org/docs/api/sso.user.m7.org/api-key-verification)
for request transports and response details.
