> For the complete documentation index, see [llms.txt](https://docs.roboflow.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.roboflow.com/developer/rest-api/manage-api-keys.md).

# Manage API Keys

You can manage your workspace's API keys programmatically using the Roboflow API - create new keys, list and inspect existing ones, rename them, attach metadata, disable, protect, and revoke them.

This is the same surface used by the [`roboflow api-key` CLI](/developer/command-line-interface/manage-api-keys.md) and the [Roboflow MCP server](/developer/mcp-server.md), so an automated agent can provision the key an application needs without a human copy-pasting from the dashboard.

{% hint style="info" %}
**Secrets are write-once.** The full key value is returned **only** when you create (or roll) a key. Every other endpoint returns a non-secret `keyId` handle plus a short `prefix` for identification - never the key itself. Store the value securely (e.g. a `.gitignore`'d `.env`) at creation time.
{% endhint %}

## Authentication

Send your API key as the `api_key` query parameter or an `Authorization: Bearer <api_key>` header, the same as every other REST endpoint (see [Authenticate with the REST API](/developer/rest-api/authenticate-with-the-rest-api.md)). The acting key must belong to the workspace in the path.

These endpoints respect Roboflow's [roles and permissions](/developer/authentication/scoped-api-keys.md). When the caller is an **OAuth token acting for a user**, the relevant RBAC actions (`create_api_key`, `update_api_key`, `revoke_api_key`, `get_api_key`, `view_workspace_api_keys`) default to workspace **owners/admins**. A request made with a scoped key (or an OAuth token acting for a user) can only create or grant abilities that the caller itself already has - see [Privilege subset rules](#privilege-subset-rules).

When the caller is a **scoped (non-OAuth) private key**, it must additionally carry the [scope](/developer/authentication/scoped-api-keys.md#available-scopes) that matches the endpoint:

| Endpoint               | Required scope   |
| ---------------------- | ---------------- |
| `GET` list / `GET` one | `api-key:read`   |
| `POST` create          | `api-key:create` |
| `PATCH` update         | `api-key:update` |
| `DELETE` revoke        | `api-key:revoke` |
| `GET` publishable      | `workspace:read` |

An unscoped (full-access) private key already satisfies all of these. A key missing the required scope is treated as if the route does not exist - see [Errors](#errors).

{% hint style="info" %}
On workspaces with **Advanced API Keys**, keys created from the dashboard are **scoped** (not full access) by default: they grant every ability **except the API-key management section** (`api-key:read`, `api-key:create`, `api-key:update`, `api-key:revoke`), so a key can't read or manage other keys unless you enable those scopes explicitly. Scopes are stored as explicit **leaf** scopes: a section umbrella (e.g. `model`) or a `role:<name>` preset is expanded to its current leaves at write time, so a key never auto-gains an ability added to that group later. Creating a **workspace-wide** key (no `folderIds`) additionally requires the caller to have access to **all** folders in the workspace.
{% endhint %}

{% hint style="warning" %}
A [publishable key](#the-publishable-key) (`rf_<workspaceId>`) is **not** accepted as authentication for these management endpoints. Authenticate with a private key.
{% endhint %}

## List API Keys

<mark style="color:green;">`GET`</mark> `/:workspace/api-keys`

Lists the workspace's API keys (masked) and returns the workspace publishable key.

**Query**

<table><thead><tr><th width="180">Name</th><th width="140">Type</th><th>Description</th><th data-type="checkbox">Required</th></tr></thead><tbody><tr><td><code>api_key</code></td><td>string</td><td>A private API key for the workspace.</td><td>true</td></tr><tr><td><code>includeDisabled</code></td><td>boolean</td><td>Include disabled keys in the result (default <code>false</code>).</td><td>false</td></tr><tr><td><code>includeFolders</code></td><td>boolean</td><td>Hydrate folder details for folder-scoped keys (default <code>false</code>).</td><td>false</td></tr></tbody></table>

**Example Request**

```bash
curl --location 'https://api.roboflow.com/<workspace_id>/api-keys?api_key=<api_key>'
```

**Response**

{% tabs %}
{% tab title="200" %}

```json
{
  "apiKeys": [
    {
      "keyId": "9f8c1a2b3d4e5f60",
      "name": "production-server",
      "prefix": "abcd",
      "scopes": ["model:infer"],
      "folderIds": [],
      "default": false,
      "protected": true,
      "disabled": false,
      "created_on": "2026-06-19T18:24:01.000Z",
      "created_by": "user_abc123",
      "customMetadata": { "env": "prod" }
    }
  ],
  "publishableKey": "rf_<workspace_id>"
}
```

{% endtab %}
{% endtabs %}

Notes:

* `keyId` is a stable, non-secret handle used to address a key in the other endpoints.
* `scopes` is `null` for an unscoped (full-access) key, or an array of [scope strings](/developer/authentication/scoped-api-keys.md#available-scopes) for a scoped key.
* `created_on` (ISO 8601) and `created_by` are included only for keys that have those values recorded. Older keys created before this attribution was tracked omit them.
* `created_by` is an **opaque** identifier of who created the key - a user id, `api_key:<handle>` (when the key was created by another API key), or `SYSTEM` (created by an automated process). Treat it as a display/audit string; do not parse it.
* `customMetadata` is included **only** when the workspace's plan includes Advanced API Keys. Without that feature the field is absent entirely (even for keys that have metadata).

## Get a Single API Key

<mark style="color:green;">`GET`</mark> `/:workspace/api-keys/:keyId`

Returns masked metadata for one key, addressed by its `keyId` handle.

**Example Request**

```bash
curl --location 'https://api.roboflow.com/<workspace_id>/api-keys/<key_id>?api_key=<api_key>'
```

**Response**

{% tabs %}
{% tab title="200" %}

```json
{ "apiKey": { "keyId": "9f8c1a2b3d4e5f60", "name": "production-server", "prefix": "abcd", "scopes": ["model:infer"], "folderIds": [], "default": false, "protected": true, "disabled": false, "created_on": "2026-06-19T18:24:01.000Z", "created_by": "user_abc123" } }
```

{% endtab %}

{% tab title="404" %}
No key with that `keyId` exists in the workspace (or it has been revoked), **or** the credential lacks the `api-key:read` scope / targets a workspace it doesn't belong to. The permission case returns the object-shaped error `{"error": {"message", "type", "hint"}}`; an unknown `keyId` returns `{"error": "string"}`. See [Errors](#errors).
{% endtab %}
{% endtabs %}

## Create an API Key

<mark style="color:green;">`POST`</mark> `/:workspace/api-keys`

Creates a new API key. The secret value is returned **once** in the `key` field.

**Headers**

| Name         | Value              |
| ------------ | ------------------ |
| Content-Type | `application/json` |

**Body**

<table><thead><tr><th width="180">Name</th><th width="200">Type</th><th>Description</th><th data-type="checkbox">Required</th></tr></thead><tbody><tr><td><code>name</code></td><td>string</td><td>A human-friendly label for the key.</td><td>false</td></tr><tr><td><code>scopes</code></td><td>Array&#x3C;string> | null</td><td>Restrict the key to these <a href="/pages/dk7mv5tf7oO5KLf9nGzV#available-scopes">scopes</a>. See the three states below. <strong>Requires Advanced API Keys.</strong></td><td>false</td></tr><tr><td><code>folderIds</code></td><td>Array&#x3C;string></td><td>Restrict the key to these project folders. <strong>Requires Advanced API Keys.</strong></td><td>false</td></tr><tr><td><code>customMetadata</code></td><td>Map&#x3C;string, string></td><td>Up to 20 key/value pairs (keys ≤100 chars, values ≤500 chars). <strong>Requires Advanced API Keys.</strong></td><td>false</td></tr><tr><td><code>protected</code></td><td>boolean</td><td>Create the key in a <a href="#protecting-a-key">protected</a> state.</td><td>false</td></tr></tbody></table>

{% hint style="info" %}
**`scopes` on create:**

* **Omitted** - the new key **inherits the calling credential's own scopes** ("create a key like me"). This is plan-independent: a full-access key creates a full-access key; a scoped key creates a key with the same scopes; folders are inherited the same way. A script that omits `scopes` behaves identically whether or not the workspace has the Advanced API Keys feature.
* **`null`** - an explicit **full-access** (unscoped) key. The caller must itself have full access (a scoped caller is rejected - see [subset rules](#privilege-subset-rules)).
* **`[]`** (empty array) - a valid key with **no abilities**; every scoped route rejects it. Useful as a placeholder you grant scopes to later.
* **`["model:infer", …]`** - **scoped** to exactly those abilities (a [section name](/developer/authentication/scoped-api-keys.md#available-scopes) such as `model` grants all of that section's scopes).
* **`["role:reviewer", …]`** - a [**role preset**](/developer/authentication/scoped-api-keys.md#role-presets): expands to that role's scopes at create time. Use a built-in role (`labeler`, `reviewer`, `owner`) or a custom role's name; `role:owner` means full access. Composable with explicit scopes.

Providing an explicit `scopes` **array** (`[]`, a list, or a `role:` preset), `folderIds`, or `customMetadata` requires the **Advanced API Keys** plan feature (otherwise `403`). Omitting `scopes` (inherit) and `null` (full) do not - so the default works on every plan.
{% endhint %}

**Example Request**

```bash
curl --location 'https://api.roboflow.com/<workspace_id>/api-keys?api_key=<api_key>' \
--header 'Content-Type: application/json' \
--data '{
    "name": "production-server",
    "scopes": ["model:infer"]
}'
```

**Response**

{% tabs %}
{% tab title="201" %}

```json
{
  "keyId": "9f8c1a2b3d4e5f60",
  "key": "AbCdEf0123456789xyz",
  "name": "production-server",
  "scopes": ["model:infer"],
  "folderIds": [],
  "protected": false,
  "publishableKey": "rf_<workspace_id>"
}
```

{% endtab %}

{% tab title="403" %}
The caller is allowed to create keys but asked to grant scopes/folders beyond what it holds, or the workspace plan does not include the requested advanced feature. Body: `{"error": "string"}`.
{% endtab %}

{% tab title="404" %}
The credential lacks the `api-key:create` scope, or targets a workspace it doesn't belong to. Body: `{"error": {"message", "type", "hint"}}`. See [Errors](#errors).
{% endtab %}
{% endtabs %}

{% hint style="danger" %}
The `key` field is the secret value and is shown **only** in this response. Save it now; you cannot retrieve it again.
{% endhint %}

## Update an API Key

<mark style="color:blue;">`PATCH`</mark> `/:workspace/api-keys/:keyId`

Updates a key's name, scopes, or metadata; protects it; or enables/disables it.

**Headers**

| Name         | Value              |
| ------------ | ------------------ |
| Content-Type | `application/json` |

**Body** (send only the fields you want to change)

<table><thead><tr><th width="180">Name</th><th width="200">Type</th><th>Description</th><th data-type="checkbox">Required</th></tr></thead><tbody><tr><td><code>name</code></td><td>string</td><td>New display name.</td><td>false</td></tr><tr><td><code>scopes</code></td><td>Array&#x3C;string> | null</td><td>New <a href="/pages/dk7mv5tf7oO5KLf9nGzV#available-scopes">scopes</a> (subset of the caller's). See the three states below. <strong>Requires Advanced API Keys.</strong></td><td>false</td></tr><tr><td><code>customMetadata</code></td><td>Map&#x3C;string, string></td><td>Replaces the key's metadata. <strong>Requires Advanced API Keys.</strong></td><td>false</td></tr><tr><td><code>protected</code></td><td><code>true</code></td><td>Protect the key. The API <strong>cannot unprotect</strong> - see below.</td><td>false</td></tr><tr><td><code>disabled</code></td><td>boolean</td><td>Disable (<code>true</code>) or re-enable (<code>false</code>) the key. <strong>Requires Advanced API Keys.</strong></td><td>false</td></tr></tbody></table>

{% hint style="info" %}
**The three states of `scopes`** (PATCH semantics differ slightly from create - omitting a field leaves it unchanged):

* **Omitted** - the key's existing scopes are **left unchanged**.
* **`null`** - the key becomes **full access** (unscoped). The caller must itself hold full access to grant this.
* **`[]`** (empty array) - the key keeps a valid credential but has **no abilities**.
* **`["model:infer", …]`** - **replaces** the key's scopes with exactly this set (a [section name](/developer/authentication/scoped-api-keys.md#available-scopes) expands to all of that section's scopes).

Sending `scopes` (**including `[]` or `null`**), `customMetadata`, or `disabled` requires the **Advanced API Keys** plan feature.
{% endhint %}

**Example Request**

```bash
curl --location --request PATCH 'https://api.roboflow.com/<workspace_id>/api-keys/<key_id>?api_key=<api_key>' \
--header 'Content-Type: application/json' \
--data '{ "name": "renamed-key" }'
```

**Response**

{% tabs %}
{% tab title="200" %}

```json
{ "apiKey": { "keyId": "9f8c1a2b3d4e5f60", "name": "renamed-key", "prefix": "abcd", "scopes": ["model:infer"], "folderIds": [], "default": false, "protected": false, "disabled": false } }
```

{% endtab %}

{% tab title="403" %}
Returned if you send `"protected": false` (the API cannot unprotect a key), or if you request scopes the caller cannot grant. Body: `{"error": "string"}`.
{% endtab %}

{% tab title="404" %}
No key with that `keyId` in the workspace (body: `{"error": "string"}`), **or** the credential lacks the `api-key:update` scope / targets a workspace it doesn't belong to (body: `{"error": {"message", "type", "hint"}}`). See [Errors](#errors).
{% endtab %}

{% tab title="409" %}
Returned if you try to disable, or change the scopes of, a key that is currently [protected](#protecting-a-key).
{% endtab %}
{% endtabs %}

## Revoke an API Key

<mark style="color:red;">`DELETE`</mark> `/:workspace/api-keys/:keyId`

Revokes (permanently deactivates) a key. Existing applications using it will immediately fail to authenticate.

**Example Request**

```bash
curl --location --request DELETE 'https://api.roboflow.com/<workspace_id>/api-keys/<key_id>?api_key=<api_key>'
```

**Response**

{% tabs %}
{% tab title="200" %}

```json
{ "status": "revoked", "keyId": "9f8c1a2b3d4e5f60" }
```

{% endtab %}

{% tab title="409" %}
The key is [protected](#protecting-a-key). Unprotect it in the Roboflow dashboard first.
{% endtab %}
{% endtabs %}

## Protecting a Key

A **protected** key cannot be disabled or revoked - by the API, the CLI, the MCP server, *or* the dashboard - until it is unprotected. Use it to stop an automated agent from accidentally taking down a production key.

* **Protect:** `PATCH` with `{ "protected": true }`.
* **Unprotect:** can **only** be done in the [dashboard](https://app.roboflow.com/settings/api). The API/CLI/MCP intentionally cannot unprotect a key, so a compromised or over-eager agent cannot remove the safety and then revoke the key in one go.

## The Publishable Key

Every workspace has a **publishable key** of the form `rf_<workspaceId>`. It is:

* **Not a secret** - safe to embed in client-side / browser code (e.g. [inferencejs](https://docs.roboflow.com/deploy/web-browser)).
* **Inference + model-download only** - it cannot manage data, train, or manage keys.
* **Permanent** - it is derived from the workspace ID, so it cannot be created, rotated, or revoked.

Read it from the `publishableKey` field on the list/create responses, or directly:

<mark style="color:green;">`GET`</mark> `/:workspace/api-keys/publishable`

```bash
curl --location 'https://api.roboflow.com/<workspace_id>/api-keys/publishable?api_key=<api_key>'
# { "publishableKey": "rf_<workspace_id>" }
```

Use the publishable key for browser/edge inference and a scoped private key for server-side work. Note that anyone holding the publishable key can run inference against (and download) that workspace's models - that is the intended trade-off of a "publishable" credential.

## Privilege Subset Rules

To prevent privilege escalation, a newly created or updated key can never have more abilities than the credential that creates it:

* When you call these endpoints with a **scoped private key**, the new key's `scopes` must be a subset of the calling key's scopes, and its `folderIds` a subset of the calling key's folders. An unscoped (full-access) key may grant anything.
* When you call them with an **OAuth token acting for a user**, the requested scopes are additionally checked against that user's role - you can only grant abilities your role allows.

Requests that exceed what the caller can grant return `403`.

## Errors

| Status | Meaning                                                                                                                                                                                                                              | Error shape                                                                                                               |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
| `400`  | Invalid request body (e.g. an unknown scope, malformed metadata).                                                                                                                                                                    | `{"error": "string"}`                                                                                                     |
| `403`  | The caller is authorized for the route but asked to **grant abilities beyond what it holds** (scopes/folders that exceed the caller's), the plan lacks Advanced API Keys, or an attempt to unprotect via API.                        | `{"error": "string"}`                                                                                                     |
| `404`  | Either no key with that `keyId` exists in the workspace, **or** the credential lacks the scope the route requires, **or** it targets a workspace the key doesn't belong to. Roboflow deliberately hides whether the resource exists. | `{"error": {"message", "type", "hint"}}` for the permission/workspace case; `{"error": "string"}` for an unknown `keyId`. |
| `409`  | The key is protected and cannot be disabled/revoked.                                                                                                                                                                                 | `{"error": "string"}`                                                                                                     |

{% hint style="warning" %}
**Two error body shapes.** Most endpoints return a **string** error - `{"error": "Some message"}`. The authentication/permission layer instead returns an **object** - `{"error": {"message": "…", "type": "…", "hint": "…"}}` (this is what you get for the permission/wrong-workspace `404` above, and for a missing or invalid key `401`). Write consumers that handle **both** shapes.
{% endhint %}

A common gotcha: a request whose credential simply lacks the route's scope returns **`404`**, not `403`. A `403` means the call *is* allowed to manage keys but tried to hand out more than the caller has.

See [Errors and Status Codes](/developer/errors-and-status-codes.md) for the general error format.
