# Technical Documentation: unkeyed/unkey

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/unkeyed/unkey)

### Overview (README.md)

<div align="center">
    <h1 align="center">Unkey</h1>
    <h5>The Developer Platform for Modern APIs</h5>
</div>

<div align="center">
  <a href="https://go.unkey.com">unkey.com</a> ·
  <a href="https://www.unkey.com/docs">Docs</a> ·
  <a href="https://go.unkey.com/discord">Discord</a>
</div>
<br/>

## What is Unkey?

**Unkey is the developer platform for modern APIs.** We unify your infrastructure so you can ship faster: deploy APIs instantly, route traffic through global gateways, and understand usage — all in one place.

- **Deploy** — push an API to production in seconds, with zero infrastructure to manage
- **Gateway** — route, authenticate, and shape traffic through globally distributed gateways
- **API Keys** — issue, verify, and revoke keys with fast global verification
- **Ratelimiting** — globally consistent, durable ratelimiting for any identifier
- **Permissions & RBAC** — per-key permissions, roles, and fine-grained access control
- **Analytics** — usage, latency, and per-key insights across every request
- **Audit logs** — immutable history of every action across your workspace


## License

Please find the license under the [LICENSE](./LICENSE) file.

## Security Issue

Please find what is in scope in the [SECURITY](./SECURITY.md) file.

## Pull requests

**Unkey is not accepting external pull requests at this time.**

Unkey has paused external code contributions while the team focuses on platform direction and stability. Pull requests opened by people outside the Unkey team will not be reviewed or merged.

Issues remain open for bug reports, feature requests, and documentation feedback. This repository remains public and source-available so you can read the code, fork it under the terms of the FSL-1.1-ALv2, and self-host. Unkey may revisit this policy in the future, and any change will be announced here.

## Let's Talk

<a href="https://unkey.cal.com/unkey/user-interview?utm_source=banner&utm_campaign=oss"><img alt="Book us with Cal.com" src="https://cal.com/book-with-cal-dark.svg" /></a>

## Authors

<a href="https://github.com/unkeyed/unkey/graphs/contributors">
  <img src="https://contrib.rocks/image?repo=unkeyed/unkey" />
</a>

---

### Index (`docs/index.mdx`)

---
title: Unkey Products
description: "Deploy apps with Compute or add API keys to any app with API Management."
sources:
  - docs/docs.json
boost: 3
mode: wide
---

Unkey issues and verifies API keys, enforces rate limits and usage quotas for any app with API Management and deploys entire apps from a GitHub repository or a container image with Compute. These offerings are flexible enough to be used individually or together.

<div className="unkey-home not-prose">
  <div className="unkey-product-grid">
    <div className="unkey-product-column">
      <h2 className="unkey-product-column-title">Compute</h2>
      <a className="unkey-product" href="/compute" aria-label="Explore Compute">
        <div className="unkey-product-art" aria-hidden="true">
          <div className="unkey-console">
            <div className="unkey-console-head">
              <span>my-app</span><span className="unkey-status">Production</span>
            </div>
            <div className="unkey-console-row">
              <span className="unkey-console-muted">01 / Build</span><span>Ready</span>
            </div>
            <div className="unkey-console-row">
              <span className="unkey-console-muted">02 / Deploy</span><span>Healthy</span>
            </div>
          </div>
        </div>
        <div className="unkey-product-copy">
          <span className="unkey-product-title">From code to production.</span>
          <span className="unkey-product-description">
            Deploy from GitHub or a container image. Build, run, and route traffic
            to your app with managed domains and a gateway. Use Compute on its own
            or add API Management.
          </span>
          <span className="unkey-product-cta">Explore Compute <span aria-hidden="true">→</span></span>
        </div>
      </a>
    </div>
    <div className="unkey-product-column">
      <h2 className="unkey-product-column-title">API Management</h2>
      <a className="unkey-product" href="/api-management" aria-label="Explore API Management">
        <div className="unkey-product-art" aria-hidden="true">
          <div className="unkey-console">
            <div className="unkey-console-head">
              <span>keys.verifyKey</span><span className="unkey-status">Valid</span>
            </div>
            <div className="unkey-console-row">
              <span className="unkey-console-muted">API key</span><span>•••• ••••</span>
            </div>
            <div className="unkey-console-row">
              <span className="unkey-console-muted">code</span><span>VALID</span>
            </div>
          </div>
        </div>
        <div className="unkey-product-copy">
          <span className="unkey-product-title">Your API. Under control.</span>
          <span className="unkey-product-description">
            Issue and verify API keys, enforce rate limits, and meter usage from
            any backend. Your app stays wherever you host it, with no dependency
            on Compute.
          </span>
          <span className="unkey-product-cta">Explore API Management <span aria-hidden="true">→</span></span>
        </div>
      </a>
    </div>
  </div>
</div>

---

### Api Management Index (`docs/api-management/index.mdx`)

---
title: API management
sidebarTitle: "Overview"
description: "Put API keys in front of an app you host anywhere."
sources:
  - svc/api/routes/register.go
  - svc/api/routes/v2_keys_create_key/handler.go
  - svc/api/routes/v2_keys_verify_key/handler.go
  - web/internal/db/src/schema/keyAuth.ts
boost: 3
---

Use API Management to issue API keys for a public API and to verify them on each request. Call `keys.createKey` to issue a key, and call `keys.verifyKey` from your backend to check it. Your app can stay on the host you already use. Compute is the other product, and you don't need it for this.

Unkey stores only a hash of each key. Verification runs the checks you configure (expiry, credits, rate limits, permissions, IP allow lists) and returns one verdict. If you already have keys, [Migrating keys](/api-management/keys/migrating-keys) explains how to import their hashes.

<Columns cols={2}>
  <Card title="Issue and verify your first key" icon="rocket" href="/api-management/get-started/quickstart">
    Create a keyspace, create a key, and verify it with curl or an SDK in a few minutes.
  </Card>
  <Card title="Keyspaces, keys, identities, and root keys" icon="sitemap" href="/api-management/get-started/concepts">
    The four objects you work with and how they fit together.
  </Card>
  <Card title="Keyspaces" icon="folder-tree" href="/api-management/keyspaces/overview">
    The container for keys: settings, defaults, and listing keys.
  </Card>
  <Card title="Creating keys" icon="key" href="/api-management/keys/creating-keys">
    Every field on `keys.createKey`, with bounds and defaults.
  </Card>
  <Card title="Verifying keys" icon="shield-check" href="/api-management/keys/verifying-keys">
    What `keys.verifyKey` checks, in which order, and what it returns.
  </Card>
  <Card title="Credits and refill" icon="coins" href="/api-management/keys/credits-and-refill">
    Meter total usage per key and refill it daily or monthly.
  </Card>
  <Card title="Rate limiting, identities, and authorization" icon="gauge-high" href="/api-management/ratelimiting/overview">
    Per-key and shared rate limits, identities, roles, and permission queries.
  </Card>
  <Card title="Analytics" icon="chart-line" href="/api-management/analytics/overview">
    Query verification and rate-limit data with SQL.
  </Card>
  <Card title="Audit logs" icon="scroll" href="/platform/workspace/audit-logs/overview">
    See who changed what in the workspace, and stream it out with a log drain.
  </Card>
</Columns>

## How this ties to Compute and Platform

Root keys authenticate your calls to the Unkey API, and both products share the CLI. You'll find both documented in [Platform](/platform). If you also host your app on Unkey, the Compute gateway can verify keys for you with its key-auth policy. See [API key authentication](/compute/gateway/api-key-auth).

---

### Compute Index (`docs/compute/index.mdx`)

---
title: Compute
sidebarTitle: "Overview"
description: "Build, deploy, and run your app on Unkey, with or without API Management."
sources:
  - docs/docs.json
  - svc/ctrl/services/app/create_app.go
  - svc/ctrl/worker/deploy/doc.go
  - cmd/deploy/control_plane.go
boost: 3
---

import { ProductLink } from "/snippets/product-link.jsx";

Compute deploys an app from a GitHub repository on every push by default, or from a container image you built yourself. Connect the repository, pick the branch, and each push becomes a <Tooltip tip="One built and running version of an app in one environment.">deployment</Tooltip> you can follow in the dashboard until it's `ready`.

You get a production and a preview <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environment</Tooltip>, automatic domains, promote and rollback, and a gateway in front of every deployment. The <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip> is the service inside a project. The steps are on [Deploy from GitHub](/compute/get-started/deploy-from-github).

## Get started

<Columns cols={2}>
  <Card title="Deploy from GitHub" icon="github" href="/compute/get-started/deploy-from-github">
    Connect a repository and let every push deploy.
  </Card>
  <Card title="Deploy a container image" icon="box" href="/compute/get-started/deploy-an-image">
    Ship an image you built yourself, from the dashboard or the CLI.
  </Card>
  <Card title="Compute plans" icon="credit-card" href="/compute/get-started/plans">
    What you need before you can deploy, and what each plan allows.
  </Card>
</Columns>

## Concepts

<Columns cols={2}>
  <Card title="Projects, apps, and environments" icon="sitemap" href="/compute/concepts/projects-apps-environments">
    How projects, apps, and environments nest.
  </Card>
  <Card title="Deployments" icon="rocket" href="/compute/concepts/deployments">
    The thirteen lifecycle statuses, the build queue, and why a deployment fails.
  </Card>
  <Card title="Production and preview" icon="code-branch" href="/compute/concepts/production-and-preview">
    Promote, roll back, stop, wake, and what happens on a timer.
  </Card>
  <Card title="Instances and autoscaling" icon="server" href="/compute/concepts/instances-and-autoscaling">
    Replicas, resource limits, health probes, and the autoscaler.
  </Card>
  <Card title="Regions" icon="earth-americas" href="/compute/concepts/regions">
    How regions are chosen, capped, and used for readiness and failover.
  </Card>
</Columns>

## Build

<Columns cols={2}>
  <Card title="Builds" icon="hammer" href="/compute/build/overview">
    Automatic builds or your Dockerfile, the build queue, caching, failures.
  </Card>
  <Card title="Dockerfile builds" icon="file-code" href="/compute/build/dockerfile">
    Build context, the runtime contract, and the errors Unkey explains.
  </Card>
  <Card title="Build-time secrets" icon="key" href="/compute/build/build-secrets">
    Use environment variables during the build without baking them in.
  </Card>
  <Card title="GitHub integration" icon="github" href="/compute/build/github">
    Install, connect, branch mapping, fork approval, and what Unkey writes back.
  </Card>
</Columns>

## Configure

<Columns cols={2}>
  <Card title="Build settings" icon="sliders" href="/compute/configure/build-settings">
    Dockerfile, root directory, build command, watch paths, auto deploy.
  </Card>
  <Card title="Runtime settings" icon="microchip" href="/compute/configure/runtime-settings">
    Port, CPU, memory, disk, command, protocol, regions, replicas.
  </Card>
  <Card title="Health checks" icon="heart-pulse" href="/compute/configure/health-checks">
    The probe that gates traffic and restarts unhealthy instances.
  </Card>
  <Card title="Environment variables" icon="key" href="/compute/configure/environment-variables">
    Kinds, limits, import, and the variables Unkey injects.
  </Card>
  <Card title="Compute limits" icon="gauge-high" href="/compute/configure/limits">
    Per-instance and workspace ceilings, per plan.
  </Card>
  <Card title="Spend budget" icon="wallet" href="/compute/configure/spend-budget">
    Alerts and an optional stop on monthly usage spend.
  </Card>
</Columns>

<ProductLink product="api-management" href="/api-management/sdks/overview" title="Every SDK covers Compute too">
  The official SDKs wrap the whole Unkey API, so projects, apps, environments, deployments, domains, gateway policies, and the GitHub install flow are reachable from the same client you use for keys.
</ProductLink>

---

### Errors Overview (`docs/errors/overview.mdx`)

---
title: "Error codes"
sidebarTitle: "Overview"
description: "What every Unkey error code means and how to fix it."
sources:
  - pkg/codes/codes.go
  - pkg/codes/constants_gen.go
  - svc/api/internal/middleware/errors.go
  - svc/api/openapi/gen.go
  - svc/frontline/middleware/observability.go
---

Every Unkey error has a code shaped like `err:{system}:{category}:{specific}`, and every code has a page here at `/errors/{system}/{category}/{specific}`. Each page tells you what the error means, shows an example response, and lists the likely causes and how to fix them.

## Reading an error code

The first part tells you where the error came from.

| System | Meaning | Returned by |
| --- | --- | --- |
| `user` | Your request can't be processed as sent: a malformed body, a query that breaks a rule, or a rate limit you went over. | The API at `api.unkey.com` |
| `unkey` | The API understood the request but couldn't complete it: authentication, permissions, a missing or conflicting resource, a failed precondition, or a failure on our side. | The API at `api.unkey.com` |
| `frontline` | The gateway in front of an app on Compute rejected a request or couldn't forward it. Often the person who sees it is calling your API, not you. | The gateway on your app's domains |

The second part groups codes by kind, and the third names the exact problem. Always read the whole code, because some names appear in two categories. `err:unkey:authentication:key_not_found` is about the root key you authenticated with. `err:unkey:data:key_not_found` is about a key you referenced in the request.

## What the API error body looks like

The API returns errors as JSON:

- `meta.requestId` identifies the request. Include it when you contact support.
- `error.type` is the error's page in this catalog.
- `error.status` repeats the HTTP status.
- `error.title` is a short label. Match on `error.type`, never on the title.
- `error.detail` explains what went wrong this time, often with the exact value or permission.

```json Not found
{
  "meta": {
    "requestId": "req_2c9a0jf23l4k567"
  },
  "error": {
    "detail": "We could not find the requested key.",
    "status": 404,
    "title": "Not Found",
    "type": "/errors/unkey/data/key_not_found"
  }
}
```

Validation failures (`err:unkey:application:invalid_input`) add an `errors` array. Each entry has a `location`, such as `/required`, and a `message`, such as `missing property 'apiId'`. Some entries also have a `fix` that says what to change.

```json Validation failure
{
  "meta": {
    "requestId": "req_8f7g6h5j4k3l2m1n"
  },
  "error": {
    "detail": "POST request body for '/v2/keys.createKey' failed to validate schema",
    "errors": [
      {
        "location": "/required",
        "message": "missing property 'apiId'"
      }
    ],
    "status": 400,
    "title": "Bad Request",
    "type": "/errors/unkey/application/invalid_input"
  }
}
```

Don't guess the status from the code's name. Every `err:unkey:data:*_not_found` code is 404, but [`err:unkey:authentication:key_not_found`](/errors/unkey/authentication/key_not_found) and [`err:unkey:authentication:portal_session_not_found`](/errors/unkey/authentication/portal_session_not_found) are 401, because what's missing is your credential.

A 5xx status means the failure is on our side, not in your request. Retry with backoff if the request is safe to repeat, and contact support with the request ID if it keeps happening.

## What the gateway error body looks like

The gateway in front of apps on Compute returns a smaller body: a request ID, a `code`, and a `message`. Each error's page gives its status.

```json Gateway error
{
  "meta": {
    "requestId": "req_3k2j1h0g9f8e7d6c"
  },
  "error": {
    "code": "err:frontline:client:invalid_key",
    "message": "Authentication failed. The provided API key is invalid."
  }
}
```

You get this JSON when your request asks for it: an `Accept` header of `application/json`, `application/*`, or `*/*` without `text/html`. Plain `curl` sends `*/*`, so it gets JSON. Everything else, including a browser, gets an HTML page with the same code, message, and request ID.

## Categories

### Unkey errors

<Columns cols={2}>
  <Card title="Authentication" icon="key" href="/errors/unkey/authentication/missing">
    The root key or session token is missing, malformed, or unknown. Platform.
  </Card>
  <Card title="Authorization" icon="lock" href="/errors/unkey/authorization/insufficient_permissions">
    You are identified but not allowed: missing permission, disabled key or workspace, expired key. Platform.
  </Card>
  <Card title="Data" icon="database" href="/errors/unkey/data/key_not_found">
    A resource does not exist, was deleted, or already exists. API Management and Compute.
  </Card>
  <Card title="Application" icon="gears" href="/errors/unkey/application/precondition_failed">
    Validation beyond the schema, preconditions including the deployment lifecycle, delete protection, and failures on our side.
  </Card>
  <Card title="Limits" icon="gauge-high" href="/errors/unkey/limits/custom_domain_limit_exceeded">
    A plan allowance is exhausted. Today that only covers Compute custom domains.
  </Card>
</Columns>

### User errors

<Columns cols={2}>
  <Card title="Bad request" icon="circle-exclamation" href="/errors/user/bad_request/request_body_too_large">
    Body size, timeouts, disconnects, permission query syntax, and analytics query rules.
  </Card>
  <Card title="Unprocessable entity" icon="hourglass-half" href="/errors/user/unprocessable_entity/query_execution_timeout">
    An analytics query was valid but exceeded its time, memory, or row budget.
  </Card>
  <Card title="Too many requests" icon="traffic-light" href="/errors/user/too_many_requests/query_quota_exceeded">
    The analytics query quota.
  </Card>
</Columns>

### Gateway errors

<Columns cols={2}>
  <Card title="Client" icon="user" href="/errors/frontline/client/invalid_key">
    The request to your app was rejected by a gateway policy: missing or invalid key, permissions, rate limit, firewall, schema.
  </Card>
  <Card title="Capacity" icon="server" href="/errors/frontline/capacity/no_running_instances">
    The deployment is offline, has no running instances, or the workspace reached its spend limit.
  </Card>
  <Card title="Routing" icon="signs-post" href="/errors/frontline/routing/config_not_found">
    No deployment or configuration matches the requested host.
  </Card>
  <Card title="Upstream" icon="cloud-arrow-up" href="/errors/frontline/upstream/bad_gateway">
    Your app did not answer, answered badly, or took too long.
  </Card>
  <Card title="Config" icon="sliders" href="/errors/frontline/config/invalid_configuration">
    The gateway configuration for the deployment is invalid.
  </Card>
  <Card title="Platform" icon="triangle-exclamation" href="/errors/frontline/platform/internal_server_error">
    The gateway failed on our side.
  </Card>
</Columns>

---

### Platform Index (`docs/platform/index.mdx`)

---
title: The Unkey platform
sidebarTitle: "Overview"
description: "Unkey hosts API management and Compute, and you can self-host the source."
sources:
  - docs/docs.json
  - README.md
  - LICENSE
  - SECURITY.md
boost: 3
---

Hosted Unkey is the dashboard at `app.unkey.com` and the API at `api.unkey.com`. API management (keys and rate limits) and Compute (deploying and running APIs) share the workspace, billing, root keys, and CLI on this page, and both run on Unkey's infrastructure. You can fork the public source and run it yourself: code outside `packages` and `internal/icons` is FSL-1.1-ALv2, these docs have no self-hosting guide, and security fixes aren't backported, so run a current build of `main` or the latest release.

Set up what Compute and API Management share: your workspace and team, billing, security, root keys, the CLI, and the HTTP API.

## Workspace

<Columns cols={2}>
  <Card title="Workspaces" icon="folder" href="/platform/workspace/overview">
    What a workspace isolates, its slug and ID, creating one.
  </Card>
  <Card title="Workspace settings" icon="sliders" href="/platform/workspace/settings">
    Name, workspace ID, and the GitHub App connection.
  </Card>
  <Card title="Team" icon="users" href="/platform/workspace/team">
    Admin and developer roles, and how invitations work.
  </Card>
  <Card title="Two-factor authentication" icon="mobile-screen" href="/platform/workspace/two-factor-auth">
    Enroll a second factor on your own account.
  </Card>
</Columns>

## Billing

<Columns cols={2}>
  <Card title="Plans" icon="credit-card" href="/platform/billing/plans">
    API tier and Compute plan, billed separately.
  </Card>
  <Card title="Limits" icon="gauge-high" href="/platform/billing/limits">
    Every ceiling, per plan.
  </Card>
  <Card title="Usage" icon="chart-line" href="/platform/billing/usage">
    What you consumed this month.
  </Card>
</Columns>

## Security

<Columns cols={2}>
  <Card title="Security" icon="shield-halved" href="/platform/security/overview">
    How we protect your keys and what you can turn on.
  </Card>
  <Card title="Key storage" icon="lock" href="/platform/security/key-storage">
    Why a key is shown once, and recoverable keys.
  </Card>
  <Card title="GitHub secret scanning" icon="github" href="/platform/security/github-secret-scanning">
    What happens when a key leaks to a public repository.
  </Card>
  <Card title="Delete protection" icon="trash-can-slash" href="/platform/security/delete-protection">
    Block deletion of keyspaces, apps, and projects.
  </Card>
</Columns>

## CLI

<Columns cols={2}>
  <Card title="The unkey CLI" icon="terminal" href="/platform/cli/overview">
    What the command groups are and which product owns each.
  </Card>
  <Card title="Install the CLI" icon="download" href="/platform/cli/install">
    npm package or GitHub release.
  </Card>
  <Card title="CLI authentication" icon="key" href="/platform/cli/authentication">
    Flag, environment variable, or `unkey auth login`, in that order.
  </Card>
  <Card title="CLI output and shared flags" icon="sliders" href="/platform/cli/output-and-flags">
    `--output json`, `--body`, `--api-url`, `--config`.
  </Card>
</Columns>

## API

<Columns cols={2}>
  <Card title="HTTP API basics" icon="globe" href="/platform/api/overview">
    Base URL, envelope, request IDs, pagination.
  </Card>
  <Card title="API authentication" icon="key" href="/platform/api/authentication">
    Bearer root keys and the errors they can produce.
  </Card>
  <Card title="RPC naming convention" icon="code" href="/platform/api/rpc-convention">
    POST /v2/{service}.{procedure}, services, verbs.
  </Card>
  <Card title="API error envelope" icon="triangle-exclamation" href="/platform/api/errors">
    RFC 7807 fields, validation errors, status codes.
  </Card>
</Columns>

## Root keys

<Columns cols={2}>
  <Card title="Root keys" icon="key-skeleton" href="/platform/root-keys/overview">
    Create, edit, rotate, and delete the credential for the API.
  </Card>
  <Card title="Root key permissions" icon="shield-halved" href="/platform/root-keys/permissions">
    Choose least-privilege permissions for deployments, agents, and API management.
  </Card>
  <Card title="Permission reference" icon="list" href="/platform/root-keys/permission-reference">
    Resource paths, supported actions, and operation requirements.
  </Card>
</Columns>

## Self-host Unkey

These docs cover hosted Unkey: the dashboard at `app.unkey.com` and the API at `api.unkey.com`. Compute and API Management both run on Unkey's infrastructure, and there's no self-hosting guide in these docs.

The [Unkey source code](https://github.com/unkeyed/unkey) is public. Code outside the `packages` and `internal/icons` directories is licensed under FSL-1.1-ALv2, so you can fork it and run it yourself under those terms. If you self-host, run a current build of `main` or the latest release. Unkey applies security fixes there and doesn't backport them to older tags.

Hosted Unkey and a fork you run are different setups.

| | Hosted Unkey | A fork you run |
| --- | --- | --- |
| Where it runs | Unkey's infrastructure, at `app.unkey.com` and `api.unkey.com` | A host you operate. These docs have no self-hosting guide |
| Source you run | Unkey operates the service | The public repo. FSL-1.1-ALv2 outside `packages` and `internal/icons` |
| Which build | The hosted service | A current build of `main` or the latest release. Security fixes aren't backported to older tags |

## Reference

<Columns cols={2}>
  <Card title="Glossary" icon="book" href="/platform/glossary">
    Every term, tagged with the product that owns it.
  </Card>
  <Card title="Compute" icon="server" href="/compute">
    Build, deploy, and run your app.
  </Card>
  <Card title="API Management" icon="key" href="/api-management">
    Keys, rate limits, identities, analytics.
  </Card>
</Columns>

---

### Api Management Analytics Overview (`docs/api-management/analytics/overview.mdx`)

---
title: Analytics
sidebarTitle: "Overview"
description: "Query your key verification and rate limit data with SQL."
sources:
  - svc/api/routes/v2_analytics_get_verifications/handler.go
  - svc/api/routes/v2_analytics_get_ratelimits/handler.go
  - svc/api/routes/v2_analytics_get_gateway_requests/handler.go
  - svc/api/routes/v2_analytics_get_runtime_logs/handler.go
  - internal/services/analytics/service.go
  - internal/services/analytics/executor.go
  - pkg/clickhouse/query-parser/tables.go
---

import { ProductLink } from "/snippets/product-link.jsx";
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

<ProductLink product="compute" href="/compute/observe/analytics-api" title="Gateway request and runtime log analytics">
  `analytics.getGatewayRequests` and `analytics.getRuntimeLogs` query Compute tables and are documented there. The query language, restrictions, and quotas on the pages here apply to them too.
</ProductLink>

Every key <Tooltip tip="Here: keys.verifyKey checking one of your users' API keys. Not domain verification and not the gateway's key-auth policy.">verification</Tooltip> and every <Tooltip tip="Here: the standalone ratelimit API and the limits attached to keys and identities. Not the gateway policy.">rate limit</Tooltip> decision is recorded, and you can query those records with SQL. Write a `SELECT` and get rows back. You only ever see your own workspace's data. Use it to build usage dashboards, bill on verifications, find keys that are hitting limits, or answer a one-off question.

## Get access

Ask [support@unkey.com](mailto:support@unkey.com) to turn on analytics for your workspace. There's no plan gate and no self-serve switch. Until it's on, the endpoints return HTTP 412 with `err:unkey:data:analytics_not_configured`. Once it's on, the [restrictions and quotas](/api-management/analytics/restrictions-and-quotas) apply.

<RootKeyPrereq />

## The endpoints

There are four, one per data set. Each takes `{"query": "<SQL>"}` and returns `data` as an array of rows, keyed by the column names you selected.

| Endpoint | Public tables | Root key permission |
| --- | --- | --- |
| `analytics.getVerifications` | `key_verifications_v1` and its per-minute, per-hour, per-day, and per-month rollups | `api.*.read_analytics`, or `api.<api_id>.read_analytics` for specific keyspaces |
| `analytics.getRatelimits` | `ratelimits_v1` and its rollups | `ratelimit.*.read_analytics`, or `ratelimit.<namespace_id>.read_analytics` for specific namespaces |
| `analytics.getGatewayRequests` | `gateway_requests_v1` | `project.*.read_gateway_requests` |
| `analytics.getRuntimeLogs` | `runtime_logs_v1` | `project.*.read_runtime_logs` |

A root key with `read_analytics` for specific keyspaces (or namespaces) instead of `*` only sees rows for those.

```bash
curl -X POST https://api.unkey.com/v2/analytics.getVerifications \
  -H "Authorization: Bearer <root key>" \
  -H "Content-Type: application/json" \
  -d '{"query": "SELECT outcome, count() AS n FROM key_verifications_v1 WHERE time > toUnixTimestamp64Milli(now() - toIntervalDay(1)) GROUP BY outcome ORDER BY n DESC"}'
```

```json Response
{
  "meta": { "requestId": "req_2c9a0jf23l4k567" },
  "data": [
    { "outcome": "VALID", "n": 48213 },
    { "outcome": "RATE_LIMITED", "n": 1207 },
    { "outcome": "USAGE_EXCEEDED", "n": 88 }
  ]
}
```

## Public tables

`key_verifications_v1` has one row per verification. `key_verifications_per_minute_v1`, `_per_hour_v1`, `_per_day_v1`, and `_per_month_v1` are rollups, much faster for charts and totals. The ratelimits tables follow the same pattern. Any other table name fails with `err:user:bad_request:invalid_analytics_table`. Columns and types are on [key_verifications tables](/api-management/analytics/tables/key-verifications) and [ratelimits tables](/api-management/analytics/tables/ratelimits).

## Where to go next

<Columns cols={2}>
  <Card title="Analytics query language" icon="code" href="/api-management/analytics/query-language">
    What SQL is accepted and which functions you can call.
  </Card>
  <Card title="Analytics restrictions and quotas" icon="gauge" href="/api-management/analytics/restrictions-and-quotas">
    Size limits, timeouts, retention, and every error code.
  </Card>
  <Card title="Analytics query examples" icon="lightbulb" href="/api-management/analytics/examples">
    Copy-ready queries for the common questions.
  </Card>
  <Card title="Analytics troubleshooting" icon="wrench" href="/api-management/analytics/troubleshooting">
    Empty results, timeouts, and rejected queries.
  </Card>
</Columns>

You can run the same queries from the CLI with `unkey api analytics get-verifications` and `unkey api analytics get-ratelimits`. The CLI reference starts at [get-verifications](/api-management/cli/analytics/get-verifications).

---

### Api Management Authorization Overview (`docs/api-management/authorization/overview.mdx`)

---
title: Authorization
sidebarTitle: "Overview"
description: "Control what each key may do with permissions checked at verification."
sources:
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/permissions/createPermission/V2PermissionsCreatePermissionRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/permissions/createRole/V2PermissionsCreateRoleRequestBody.yaml
  - svc/api/routes/v2_keys_verify_key/handler.go
  - internal/services/keys/validation.go
  - pkg/rbac/rbac.go
  - pkg/rbac/permissions.go
  - web/internal/db/src/schema/rbac.ts
---

Verification tells you a key is real. Authorization tells you what it's allowed to do. Give each key permissions, directly or through roles. Then, when you verify the key, ask whether it has the permissions a request needs. If it doesn't, verification fails with `code: INSUFFICIENT_PERMISSIONS` and no rate limit or credits are used.

## The pieces

A **permission** is a string you define, such as `documents.read` or `billing:write`. It has a readable `name`, a `slug` that keys and queries use, and an optional description. It means whatever your API decides it means.

A **role** is a named group of permissions, such as `editor` with `documents.read` and `documents.write`. A key with the role gets all its permissions, and changing the role changes every key that has it.

A **key** can have permissions, roles, or both. Its permissions are its own plus those from its roles. Verification returns them as `data.permissions`, and the roles as `data.roles`.

A **permission query** is what you send as `permissions` on `keys.verifyKey`: one slug, or slugs combined with `AND`, `OR`, and parentheses. Verification passes only if the key's permissions satisfy it.

```bash
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "key": "sk_live_...", "permissions": "documents.write OR admin" }'
```

## Key permissions vs root key permissions

The permissions on this page belong to your users' keys, and your API decides what they allow. Root key permissions, such as `api.*.create_key`, control what your root keys can do on `api.unkey.com`. The two never mix. See [Root key permissions](/platform/root-keys/permissions).

## When to check where

Send a `permissions` query when the answer depends only on the key: "can this key write documents?" Unkey answers as part of the verification. Check `data.permissions` in your own code when the answer also depends on your data: "can this key delete *this* document?" needs the document's owner, which Unkey doesn't know.

## Next steps

<Columns cols={2}>
  <Card title="Roles and permissions" icon="shield" href="/api-management/authorization/roles-and-permissions">
    Create permissions and roles through the API or the dashboard, with slug rules and bounds.
  </Card>
  <Card title="Permission queries" icon="code" href="/api-management/authorization/permission-queries">
    The query grammar, its limits, and why an asterisk isn't a wildcard.
  </Card>
  <Card title="Managing key roles and permissions" icon="key" href="/api-management/authorization/managing-key-permissions">
    Attach, replace, and remove roles and permissions on a key.
  </Card>
  <Card title="Verifying keys" icon="shield-check" href="/api-management/keys/verifying-keys">
    Where the permission check sits among the other verification checks.
  </Card>
</Columns>

---

### Api Management Cookbook Index (`docs/api-management/cookbook/index.mdx`)

---
title: Cookbook
sidebarTitle: "Overview"
description: "Recipes for rate limiting, billing, and framework middleware built on Unkey."
sources:
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/ratelimit/limit/V2RatelimitLimitRequestBody.yaml
---
Each recipe solves one common problem from start to finish. They assume you already have a keyspace, a root key, and a request handler that calls `keys.verifyKey`. If you don't yet, start with a [framework guide](/api-management/guides/nextjs).

<Columns cols={2}>
  <Card title="Per-user rate limits" href="/api-management/cookbook/per-user-rate-limit" icon="user-clock">
    Share one limit across all of a user's keys with identities, and give tiers different limits with overrides.
  </Card>
  <Card title="Per-endpoint rate limits" href="/api-management/cookbook/endpoint-rate-limit" icon="route">
    Separate budgets for reads and writes, with named key limits or a namespace per route.
  </Card>
  <Card title="Usage-based billing with credits" href="/api-management/cookbook/usage-billing" icon="coins">
    Meter requests with credits, refill monthly, charge more for heavy operations, and sell top-ups.
  </Card>
  <Card title="Tiered subscriptions" href="/api-management/cookbook/tiered-subscriptions" icon="layer-group">
    Encode free, pro, and enterprise plans in a key's limits, credits, permissions, and metadata, and change plans in place.
  </Card>
  <Card title="Go middleware for net/http, Gin, and Echo" href="/api-management/cookbook/go-middleware" icon="golang">
    Drop-in authentication middleware for the three common Go HTTP stacks.
  </Card>
  <Card title="FastAPI authentication" href="/api-management/cookbook/fastapi-auth" icon="python">
    A typed FastAPI dependency with permission checks and rate limit headers.
  </Card>
</Columns>

---

### Api Management Get Started Quickstart (`docs/api-management/get-started/quickstart.mdx`)

---
title: Issue and verify your first key
sidebarTitle: "Quickstart"
description: "Create a keyspace and verify your first key in a few minutes."
sources:
  - svc/api/routes/v2_keys_create_key/handler.go
  - svc/api/routes/v2_keys_verify_key/handler.go
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
  - web/apps/dashboard/lib/trpc/routers/api/create.ts
  - web/internal/api/src/sdk/sdk.ts
  - web/internal/api/src/sdk/keys.ts
  - https://github.com/unkeyed/sdks/tree/main/api/go/v3
  - https://github.com/unkeyed/sdks/blob/main/api/py/README.md
---

import { ProductLink } from "/snippets/product-link.jsx";
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

Issue API keys for your public API with `keys.createKey`, then verify each request with `keys.verifyKey`. Both are HTTPS calls to `api.unkey.com`, from whatever language and host you already use. If you already have keys in your own database, [Migrating keys](/api-management/keys/migrating-keys) shows how to import the hashes, so users keep the same keys and you stop storing the plaintext.

Create a keyspace, issue a key, and <Tooltip tip="Key verification: keys.verifyKey hashes the presented key, finds it, runs the configured checks, and reports whether it is valid and why not. Not domain verification or the Compute gateway policy.">verify</Tooltip> it from your backend.

<RootKeyPrereq />

The root key you create needs `api.*.create_key` and `api.*.verify_key`. Pick your language with the switcher. Only the steps that call the API change.

<Steps titleSize="h3">
  <Step title="Create a keyspace">
    A keyspace holds the keys for one product, environment, or tier. In the dashboard, open **Keyspaces (APIs)** in the sidebar, click **Create keyspace**, and give it a name. Copy the **API ID** (`api_...`) from its settings page. You pass it when you create keys.

    <Frame>
      <img src="/images/dashboard/api-management--get-started-quickstart--create-keyspace.png" alt="Create keyspace dialog with a name entered and the Create Keyspace button" />
    </Frame>
  </Step>
</Steps>

<View title="curl">
  <Steps titleSize="h3">
    <Step title="Create a key">
      Call `keys.createKey` with the API ID. The `name` is only for you. Your users never see it.

      ```bash
      curl -X POST https://api.unkey.com/v2/keys.createKey \
        -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
        -H "Content-Type: application/json" \
        -d '{
          "apiId": "api_...",
          "name": "My first key"
        }'
      ```

      This is the only time you see the key. Copy `data.key` now and give it to your user.

      ```json
      {
        "meta": { "requestId": "req_..." },
        "data": {
          "keyId": "key_...",
          "key": "..."
        }
      }
      ```
    </Step>
    <Step title="Verify the key">
      This is the call your backend makes on every incoming request. There's no `apiId` field because the key alone identifies its keyspace.

      ```bash
      curl -X POST https://api.unkey.com/v2/keys.verifyKey \
        -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
        -H "Content-Type: application/json" \
        -d '{ "key": "<the key from the previous step>" }'
      ```
    </Step>
  </Steps>
</View>

<View title="TypeScript">
  <Steps titleSize="h3">
    <Step title="Install the SDK">
      ```bash
      npm install @unkey/api
      ```
    </Step>
    <Step title="Create a key">
      ```typescript create-key.ts
      import { Unkey } from "@unkey/api";

      const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY });

      const { data } = await unkey.keys.createKey({
        apiId: "api_...",
        name: "My first key",
      });

      // Shown once. Store it or send it to your user now.
      console.log(data.key);
      ```
    </Step>
    <Step title="Verify the key">
      There's no `apiId` field because the key alone identifies its keyspace.

      ```typescript verify-key.ts
      const { data } = await unkey.keys.verifyKey({ key: incomingKey });

      if (!data.valid) {
        // data.code says why: NOT_FOUND, EXPIRED, RATE_LIMITED, ...
        throw new Error(`denied: ${data.code}`);
      }

      console.log(data.keyId);
      ```
    </Step>
  </Steps>
</View>

<View title="Go">
  <Steps titleSize="h3">
    <Step title="Install the SDK">
      ```bash
      go get github.com/unkeyed/sdks/api/go/v3@latest
      ```
    </Step>
    <Step title="Create a key">
      ```go main.go
      package main

      import (
      	"context"
      	"fmt"
      	"os"

      	unkey "github.com/unkeyed/sdks/api/go/v3"
      	"github.com/unkeyed/sdks/api/go/v3/models/components"
      )

      func main() {
      	client := unkey.New(unkey.WithSecurity(os.Getenv("UNKEY_ROOT_KEY")))

      	res, err := client.Keys.CreateKey(context.Background(), components.V2KeysCreateKeyRequestBody{
      		APIID: "api_...",
      		Name:  unkey.String("My first key"),
      	})
      	if err != nil {
      		panic(err)
      	}

      	// Shown once. Store it or send it to your user now.
      	fmt.Println(res.V2KeysCreateKeyResponseBody.Data.Key)
      }
      ```
    </Step>
    <Step title="Verify the key">
      There's no `apiId` field because the key alone identifies its keyspace.

      ```go verify.go
      res, err := client.Keys.VerifyKey(ctx, components.V2KeysVerifyKeyRequestBody{
      	Key: incomingKey,
      })
      if err != nil {
      	return err
      }

      data := res.V2KeysVerifyKeyResponseBody.Data
      if !data.Valid {
      	// data.Code says why: NOT_FOUND, EXPIRED, RATE_LIMITED, ...
      	return fmt.Errorf("denied: %s", data.Code)
      }
      ```
    </Step>
  </Steps>
</View>

<View title="Python">
  <Steps titleSize="h3">
    <Step title="Install the SDK">
      ```bash
      pip install unkey.py
      ```
    </Step>
    <Step title="Create a key">
      ```python create_key.py
      import os
      from unkey.py import Unkey

      with Unkey(root_key=os.environ["UNKEY_ROOT_KEY"]) as unkey:
          res = unkey.keys.create_key(api_id="api_...", name="My first key")

          # Shown once. Store it or send it to your user now.
          print(res.data.key)
      ```
    </Step>
    <Step title="Verify the key">
      There's no `api_id` argument because the key alone identifies its keyspace.

      ```python verify_key.py
      res = unkey.keys.verify_key(key=incoming_key)

      if not res.data.valid:
          # res.data.code says why: NOT_FOUND, EXPIRED, RATE_LIMITED, ...
          raise PermissionError(f"denied: {res.data.code}")
      ```
    </Step>
  </Steps>
</View>

## How you read the verification response

Verification returns HTTP 200 even when the key is rejected. `data.valid` tells you whether the key passed. When it didn't, `data.code` says why.

```json
{
  "meta": { "requestId": "req_..." },
  "data": {
    "valid": true,
    "code": "VALID",
    "keyId": "key_...",
    "name": "My first key",
    "enabled": true
  }
}
```

The response gets more fields as you set more on the key, such as `credits`, `ratelimits`, `meta`, and `identity`. [Verifying keys](/api-management/keys/verifying-keys) lists every field and every `code` value.

## Next steps

<Columns cols={2}>
  <Card title="Creating keys" icon="key" href="/api-management/keys/creating-keys">
    Prefixes, expiry, credits, rate limits, permissions, and metadata on one key.
  </Card>
  <Card title="Verifying keys" icon="shield-check" href="/api-management/keys/verifying-keys">
    The order of checks, the `code` enum, and caching behavior.
  </Card>
  <Card title="Credits and refill" icon="coins" href="/api-management/keys/credits-and-refill">
    Sell a fixed number of requests and refill them on a schedule.
  </Card>
  <Card title="Keyspaces, keys, identities, and root keys" icon="sitemap" href="/api-management/get-started/concepts">
    How the objects you just used fit together.
  </Card>
</Columns>

<ProductLink product="compute" href="/compute/gateway/api-key-auth" title="Verify at the gateway instead">
  If you host your app on Unkey, the gateway's key-auth policy can run this verification before a request reaches your code.
</ProductLink>

---

### Api Management Identities Overview (`docs/api-management/identities/overview.mdx`)

---
title: Identities
sidebarTitle: "Overview"
description: "Tie a user's keys together so they share metadata and rate limits."
sources:
  - svc/api/openapi/spec/paths/v2/identities/createIdentity/V2IdentitiesCreateIdentityRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/identities/createIdentity/V2IdentitiesCreateIdentityResponseData.yaml
  - svc/api/openapi/spec/paths/v2/identities/updateIdentity/V2IdentitiesUpdateIdentityRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/identities/getIdentity/V2IdentitiesGetIdentityRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/identities/listIdentities/V2IdentitiesListIdentitiesRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/identities/deleteIdentity/V2IdentitiesDeleteIdentityRequestBody.yaml
  - svc/api/openapi/spec/common/Identity.yaml
  - svc/api/openapi/spec/common/RatelimitRequest.yaml
  - svc/api/routes/v2_identities_create_identity/handler.go
  - svc/api/routes/v2_identities_update_identity/handler.go
  - svc/api/routes/v2_identities_delete_identity/handler.go
  - svc/api/routes/v2_identities_get_identity/handler.go
  - svc/api/routes/v2_identities_list_identities/handler.go
  - svc/api/routes/v2_keys_create_key/handler.go
  - svc/api/routes/v2_keys_verify_key/handler.go
  - web/internal/db/src/schema/identity.ts
---

import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

An identity is a user, organization, or service account in your system that owns one or more keys. You name it with an `externalId`, usually that user's ID in your own database. Keys linked to an identity share its metadata and its rate limits, so a customer can't get around a limit by creating another key.

## What an identity holds

<ResponseField name="id" type="string" required>
  Unkey's ID, `id_...`. You can use it anywhere you name an identity.
</ResponseField>

<ResponseField name="externalId" type="string" required>
  Your ID, 1 to 255 characters matching `^[a-zA-Z0-9_.-]+$`, unique within the workspace. You can also use it anywhere you name an identity, so you rarely need to store `id`.
</ResponseField>

<ResponseField name="meta" type="object">
  JSON with at most 100 top-level properties and at most 1 MB. Returned as `identity.meta` when any of the identity's keys is verified.
</ResponseField>

<ResponseField name="ratelimits" type="object[]">
  Up to 50 named limits, each with `id`, `name`, `limit`, `duration`, and `autoApply`, shared by every key of the identity. See [Shared rate limits across keys](/api-management/identities/shared-rate-limits).
</ResponseField>

## Create an identity by issuing a key

The easiest way to create an identity is to pass `externalId` when you create a key (with `keys.createKey` or `keys.migrateKeys`). Unkey creates the identity if it doesn't exist and links the key. Most apps never call `identities.createIdentity`. Call it when you want metadata or shared limits in place before the first key exists, or when you manage identities from somewhere that doesn't issue keys, such as a billing webhook.

```bash
curl -X POST https://api.unkey.com/v2/keys.createKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "apiId": "api_...", "externalId": "user_123" }'
```

Verifying that key now returns the identity:

```json
{
  "meta": { "requestId": "req_..." },
  "data": {
    "valid": true,
    "code": "VALID",
    "keyId": "key_...",
    "identity": { "id": "id_...", "externalId": "user_123", "meta": {} }
  }
}
```

To move a key to another identity, set `externalId` on `keys.updateKey`. Send `null` to unlink it.

<RootKeyPrereq />

You need `identity.*.create_identity`, `identity.*.read_identity`, `identity.*.update_identity`, or `identity.*.delete_identity` for those calls.

## Create an identity

<ParamField body="externalId" type="string" required>
  1 to 255 characters, `^[a-zA-Z0-9_.-]+$`. A second identity with the same `externalId` fails with HTTP 409 [`err:unkey:data:identity_already_exists`](/errors/unkey/data/identity_already_exists).
</ParamField>

<ParamField body="meta" type="object">
  At most 100 top-level properties, and at most 1 MB once serialized to JSON. A larger object is rejected with HTTP 400 [`err:unkey:application:invalid_input`](/errors/unkey/application/invalid_input).
</ParamField>

<ParamField body="ratelimits" type="object[]">
  Up to 50 limits, each with a unique `name`. Every entry needs `name` (3 to 128 characters), `limit` (1 or more), `duration` in milliseconds (1000 or more), and `autoApply` (required).
</ParamField>

```bash
curl -X POST https://api.unkey.com/v2/identities.createIdentity \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "externalId": "org_acme",
    "meta": { "plan": "enterprise", "billingEmail": "ops@acme.example" },
    "ratelimits": [ { "name": "requests", "limit": 1000, "duration": 60000, "autoApply": true } ]
  }'
```

The response carries `identityId`.

## Find identities

`identities.getIdentity` takes `identity` (the `id` or `externalId`) and returns the fields above. An unknown value returns HTTP 404 `err:unkey:data:identity_not_found`.

`identities.listIdentities` takes `limit` (1 to 100, default 100), `cursor`, and an optional `search`. Search matches any part of the `id` or `externalId`, ignoring case. The response has `data` and `pagination`.

## Update an identity

`identities.updateIdentity` takes `identity` (id or externalId) plus any of:

<ParamField body="meta" type="object">
  Replaces all metadata. Omit it to keep the current metadata, or send `{}` to clear it. The same 100-property and 1 MB limits apply.
</ParamField>

<ParamField body="ratelimits" type="object[]">
  Replaces the whole list. Limits you leave out are deleted, matching names are updated, and new names are added. The same bounds as create apply. A repeated name fails with HTTP 400 `err:unkey:application:invalid_input`. Leave it out to keep the current limits.
</ParamField>

You can't rename an identity's `externalId`. Create a new identity and move the keys with `keys.updateKey` instead.

## Delete an identity

`identities.deleteIdentity` takes `identity` (id or externalId). The keys aren't deleted, but they stop returning `identity` on verification and stop sharing its rate limits. You can reuse the `externalId` for a new identity.

## From the dashboard

Open **Identities** in the sidebar to search, create, edit, or delete identities. Each identity's page shows the verification history of all its keys together.

<Frame>
  <img src="/images/dashboard/api-management--identities-overview--identity.png" alt="Identity page with a requests chart and a verification log table, both empty for an identity with no verifications yet" />
</Frame>

---

### Api Management Keyspaces Overview (`docs/api-management/keyspaces/overview.mdx`)

---
title: Keyspaces
sidebarTitle: "Overview"
description: "Group related keys in a keyspace and decide when you need more than one."
sources:
  - web/internal/db/src/schema/apis.ts
  - web/internal/db/src/schema/keyAuth.ts
  - svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/apis/getApi/V2ApisGetApiResponseData.yaml
  - svc/api/routes/v2_apis_create_api/handler.go
  - svc/api/routes/v2_apis_delete_api/handler.go
  - svc/api/routes/v2_keys_create_key/handler.go
  - internal/services/keys/get.go
  - web/apps/dashboard/lib/trpc/routers/api/create.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/apis/_components/create-api-button.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/settings/components/delete-api.tsx
---

import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

A keyspace is a group of related API keys. Every key belongs to one keyspace, and you name it every time you create a key. It's not a running service and has no URL. The dashboard lists keyspaces under **Keyspaces (APIs)**, and API endpoints call them APIs, as in `apis.createApi`.

## When to create more than one

One keyspace is enough when all your keys share the same defaults and you tell them apart by metadata or identity. Create more when you want a hard boundary: different key prefixes per product, separate analytics per <Tooltip tip="An optional free-text label stored on a key in the dashboard, such as live or test. Unkey attaches no behavior to it, and it is unrelated to Compute environments.">environment</Tooltip> such as production and staging, or root keys that can only touch one product's keys. Root key permissions such as `api.<api_id>.create_key` can be limited to one keyspace.

## Two identifiers

Each keyspace has two IDs, used in different places.

| Identifier | Format | Used by |
| --- | --- | --- |
| API ID | `api_...` | `keys.createKey`, `apis.listKeys`, `apis.getApi`, `apis.deleteApi`, and root key permissions such as `api.<api_id>.verify_key` |
| Keyspace ID | `ks_...` | Analytics tables (`key_space_id`), the `keyspaces` filter on `keys.verifyKey`, and the customer portal |

Both appear on the keyspace's **Settings** page with a copy button.

## Settings that apply to every key

These settings apply to every key in the keyspace. [Keyspace settings](/api-management/keyspaces/settings) covers each one.

| Setting | Effect |
| --- | --- |
| Default prefix | Prepended to new keys that do not pass their own `prefix`. |
| Default bytes | Random bytes used for new keys that do not pass their own `byteLength`; 16 unless changed. |
| Store encrypted keys | Allows keys to be created as recoverable; opt-in through support. |
| IP allow list | Verification rejects requests from any other client IP; exact match, not editable in the dashboard. |
| Delete protection | Blocks `apis.deleteApi` and the dashboard delete button while on. |

## Create a keyspace

<RootKeyPrereq />

<Steps titleSize="h3">
  <Step title="From the dashboard">
    Open **Keyspaces (APIs)** in the sidebar, click **Create keyspace**, and enter a name. Both IDs are on the new keyspace's settings page.

    <Frame>
      <img src="/images/dashboard/api-management--keyspaces-overview--list.png" alt="Keyspaces page showing keyspace cards with their key counts and the Create keyspace button" />
    </Frame>
  </Step>
  <Step title="From the API">
    `apis.createApi` takes a `name` (3 to 256 characters, just a label) and needs `api.*.create_api` on the root key. The response has the new API ID.

    ```bash
    curl -X POST https://api.unkey.com/v2/apis.createApi \
      -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "name": "payment-service-prod" }'
    ```

    `apis.getApi` returns the `id` and `name` for an API ID you already have.
  </Step>
</Steps>

## Delete a keyspace

Deleting a keyspace makes every key in it verify as `NOT_FOUND`. In the dashboard, use the **Delete Keyspace** card in the settings danger zone and type the keyspace name and the confirmation phrase. From the API, call `apis.deleteApi` with the `apiId`. It needs `api.*.delete_api` or `api.<api_id>.delete_api`.

If delete protection is on, both fail with HTTP 412 `err:unkey:application:protected_resource`. Turn protection off on the settings page first. See [Delete protection](/platform/security/delete-protection).

## Next steps

<Columns cols={2}>
  <Card title="Keyspace settings" icon="sliders" href="/api-management/keyspaces/settings">
    Defaults, encrypted storage, IP allow list, and delete protection.
  </Card>
  <Card title="Listing keys" icon="list" href="/api-management/keyspaces/listing-keys">
    Page through the keys in a keyspace and filter by owner.
  </Card>
</Columns>

---

### Api Management Portal Overview (`docs/api-management/portal/overview.mdx`)

---
title: Developer portal
sidebarTitle: "Overview"
description: "A hosted page where your users manage their own API keys."
sources:
  - svc/api/openapi/spec/common/Portal.yaml
  - svc/api/openapi/spec/common/PortalBranding.yaml
  - svc/api/openapi/spec/paths/v2/portal/createPortal/V2PortalCreatePortalRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/portal/createPortal/index.yaml
  - svc/api/openapi/spec/paths/v2/portal/createSession/index.yaml
  - cmd/api/portal/create_portal.go
  - cmd/api/portal/create_session.go
  - svc/api/openapi/spec/paths/v2/portal/updatePortal/V2PortalUpdatePortalRequestBody.yaml
  - svc/api/routes/v2_portal_create_session/handler.go
  - svc/api/routes/v2_portal_update_portal/handler.go
  - svc/api/routes/v2_portal_delete_portal/handler.go
  - web/internal/db/src/schema/portals.ts
  - pkg/rbac/permissions.go
  - pkg/codes/constants_gen.go
---

import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

The developer portal is a hosted page where your users see their API keys, roll them over, and check their usage, without you building any of it. You keep the sign-in: your backend creates a short-lived session for a user who's already signed in, and redirects them to the portal. Each user only sees their own keys.

<Note>
  The developer portal is in beta.

  You can manage a portal for a keyspace in the dashboard, with the API, or with the CLI ([create-portal](/api-management/cli/portal/create-portal), [get-portal](/api-management/cli/portal/get-portal), [update-portal](/api-management/cli/portal/update-portal), [delete-portal](/api-management/cli/portal/delete-portal), [create-session](/api-management/cli/portal/create-session)). A portal for a deployed app can only be managed with the API or the CLI for now.
</Note>

## What a portal is bound to

A portal shows keys from one place. Set `keyspaceId` to show the keys in a keyspace. Or set `appId` to a Compute <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip> to show keys from every keyspace the gateway accepts for that app. Send one or the other, not both. You can change it later.

A user only sees keys whose identity `externalId` matches the one in their session. Keys without an identity never show up in the portal, so attach identities to keys you want users to manage.

## Fields

<ParamField body="slug" type="string" required>
  A handle for the portal in API calls, unique in your workspace. 3 to 64 lowercase letters, digits, and hyphens, not starting or ending with a hyphen. Your users never see it. You can use the slug or the `pc_` ID wherever you name a portal. A taken slug, or a second portal for the same keyspace or app, fails with HTTP 409 `err:unkey:data:portal_already_exists`.
</ParamField>
<ParamField body="displayName" type="string" required>
  1 to 64 characters shown in the portal header and page titles. Change it any time.
</ParamField>
<ParamField body="keyspaceId" type="string">
  The keyspace to serve. Mutually exclusive with `appId`.
</ParamField>
<ParamField body="appId" type="string">
  The app to serve. Mutually exclusive with `keyspaceId`.
</ParamField>
<ParamField body="enabled" type="boolean" default="true">
  Whether new sessions can be created. Disabling also ends sessions that are already live.
</ParamField>
<ParamField body="logoUrl" type="string">
  `https://` URL of the logo in the portal header, up to 500 characters. Your users' browsers load it directly, so that host sees their IP address. Set `null` on update to remove it.
</ParamField>
<ParamField body="primaryColor" type="string">
  Six-digit hex color (`#6366f1`) for buttons and accents. Set `null` on update to go back to the default.
</ParamField>

When you read a portal, `logoUrl` and `primaryColor` come back under `branding`, and `createdAt` and `updatedAt` are Unix milliseconds.

## Create a portal

<RootKeyPrereq />

Creating a portal needs `portal.*.create_portal`. Reading, updating, and deleting need `read_portal`, `update_portal`, or `delete_portal`, either as `portal.*.<action>` or scoped to one portal as `portal.<portalId>.<action>`. Creating sessions needs a separate permission, covered on [Portal sessions](/api-management/portal/sessions).

```bash
curl -X POST https://api.unkey.com/v2/portal.createPortal \
  -H "Authorization: Bearer <root key>" \
  -H "Content-Type: application/json" \
  -d '{
    "slug": "acme-portal",
    "displayName": "Acme",
    "keyspaceId": "ks_1234abcd",
    "primaryColor": "#6366f1"
  }'
```

You get the portal back with its `pc_` ID. Next, [create a session](/api-management/portal/sessions) for a signed-in user and redirect them to the URL you get back.

## Change or remove a portal

`portal.updatePortal` takes the same fields, all optional. Leave a field out to keep it. Pointing the portal at a different keyspace or app, or disabling it, ends every live session.

`portal.deletePortal` deletes the portal, ends all its live sessions, and frees the slug. Both write an [audit log](/platform/workspace/audit-logs/overview) entry (`portal.update` or `portal.delete`).

## Next steps

<Columns cols={2}>
  <Card title="Portal sessions" icon="ticket" href="/api-management/portal/sessions">
    Create a session for a signed-in user, redirect them to the portal, and set the permissions your root key needs.
  </Card>
  <Card title="What portal users can do" icon="user" href="/api-management/portal/end-user-experience">
    The keys and analytics tabs, what each scope unlocks, and the endpoints behind them.
  </Card>
</Columns>

---

### Api Management Ratelimiting Overview (`docs/api-management/ratelimiting/overview.mdx`)

---
title: Rate limiting with Unkey
sidebarTitle: "Overview"
description: "Unkey rate limits your API per customer, key, endpoint, or IP, and Compute limits it at the gateway."
sources:
  - svc/api/routes/v2_ratelimit_limit/handler.go
  - svc/api/openapi/spec/paths/v2/ratelimit/limit/V2RatelimitLimitRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/ratelimit/limit/V2RatelimitLimitResponseData.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
  - svc/api/openapi/spec/common/KeyCreditsRefill.yaml
  - svc/frontline/internal/policies/ratelimit/headers.go
  - svc/frontline/internal/policies/ratelimit/keyextract.go
  - svc/api/openapi/spec/common/RemoteIpKey.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/KeysVerifyKeyRatelimit.yaml
  - svc/api/openapi/spec/paths/v2/identities/createIdentity/V2IdentitiesCreateIdentityRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/identities/updateIdentity/V2IdentitiesUpdateIdentityRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
  - web/internal/api/src/sdk/identities.ts
  - svc/api/internal/middleware/authentication.go
  - internal/services/ratelimit/doc.go
  - internal/services/keys/validation.go
  - pkg/rbac/permissions.go
  - web/internal/api/src/sdk/ratelimit.ts
  - web/internal/api/src/sdk/keys.ts
---

import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
import { versions } from "/snippets/versions.mdx";

Per customer, Unkey counts on an identity, so extra keys don't add budget.

You can also limit per key, per endpoint with a named limit or a `cost`, and per IP with `ratelimit.limit`.

Monthly quotas are separate, as credits on the key.

On Compute, a gateway policy applies these limits before the request reaches your API.

## Which one do I use

Use the row that matches what you want to count.

| You want to limit | What Unkey uses | Read more |
| --- | --- | --- |
| Per customer | A rate limit on the identity, shared by every key with that `externalId` | [Shared rate limits](/api-management/identities/shared-rate-limits) |
| Per key | A named rate limit on the key, checked during `keys.verifyKey` | [Key and identity rate limits](/api-management/ratelimiting/key-and-identity-limits) |
| Per endpoint | A named limit the route opts into, or a `cost` on that call. With no key, one namespace per route on `ratelimit.limit` | [Per-endpoint rate limits](/api-management/cookbook/endpoint-rate-limit) |
| Per IP | The IP as the identifier on `ratelimit.limit`. On Compute, a gateway policy can count by remote IP | [ratelimit.limit](/api-management/ratelimiting/limit-and-multi-limit), [Rate limit policy](/compute/gateway/rate-limiting) |
| Monthly quota | Credits on the key, with `credits.refill.interval` set to `monthly` | [Credits and refill](/api-management/keys/credits-and-refill) |

## Limit per customer

Create the identity with the shared limit, then create each key with the same `externalId`. `keys.verifyKey` counts all of those keys together. The client is `@unkey/api` {versions.tsApi}.

```typescript
import { Unkey } from "@unkey/api";

const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY });

await unkey.identities.createIdentity({
  externalId: "cus_acme",
  ratelimits: [
    { name: "requests", limit: 100, duration: 60000, autoApply: true },
  ],
});

await unkey.keys.createKey({ apiId: "api_...", externalId: "cus_acme" });

export async function handler(key: string) {
  const { data } = await unkey.keys.verifyKey({ key });

  if (data.valid) {
    return new Response("ok");
  }

  if (data.code === "RATE_LIMITED") {
    return new Response("Too many requests", { status: 429 });
  }

  return new Response("Unauthorized", { status: 401 });
}
```

To override that customer's limit, call `identities.updateIdentity` with `identity` set to the same `externalId` and a new `ratelimits` array. The array replaces the whole list, so send every limit the customer should keep. See [Per-user rate limits](/api-management/cookbook/per-user-rate-limit).

Unkey enforces per-user and per-customer rate limits, and a monthly request quota, on an API you host yourself. You don't run Redis or any other counter store. Attach both to an API key, or call `ratelimit.limit` with a user id, a customer id, or any other stable string.

A rate limit is a sliding window, such as 100 requests per minute. A monthly quota is credits on the key: set `credits.refill.interval` to `monthly`, and each successful `keys.verifyKey` spends from that balance. Credits are an exact total. Rate limit counts are close, and they can run a little high when traffic is split across regions. See [Credits and refill](/api-management/keys/credits-and-refill).

## Add a per-key limit

Create the key with a per-minute limit and a monthly credit refill, then verify it on each request. This limit counts that key alone. `externalId` still ties the key to one customer.

```typescript
import { Unkey } from "@unkey/api";

const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY });

await unkey.keys.createKey({
  apiId: "api_...",
  externalId: "cus_acme",
  credits: {
    remaining: 10000,
    refill: { interval: "monthly", amount: 10000, refillDay: 1 },
  },
  ratelimits: [
    { name: "requests", limit: 100, duration: 60000, autoApply: true },
  ],
});

export async function handler(request: Request) {
  const header = request.headers.get("authorization") ?? "";
  const key = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : header;
  const { data } = await unkey.keys.verifyKey({ key });

  if (data.valid) {
    return new Response("ok");
  }

  if (data.code === "RATE_LIMITED") {
    const resetMs = data.ratelimits?.find((limit) => limit.exceeded)?.reset ?? Date.now();
    const retryAfter = Math.max(1, Math.ceil((resetMs - Date.now()) / 1000));
    return new Response("Too many requests", {
      status: 429,
      headers: { "Retry-After": String(retryAfter) },
    });
  }

  if (data.code === "USAGE_EXCEEDED") {
    return new Response("Monthly quota exceeded", { status: 429 });
  }

  return new Response("Unauthorized", { status: 401 });
}
```

`duration` is the window in milliseconds. `autoApply: true` checks that limit on every verification. The monthly refill replaces the balance on `refillDay` at 00:00 UTC. It doesn't add to what's left.

For an endpoint with no API key, call `ratelimit.limit` with your own identifier instead. The same pattern is on [ratelimit.limit and multiLimit](/api-management/ratelimiting/limit-and-multi-limit).

## What a limited request looks like

`keys.verifyKey` and `ratelimit.limit` return HTTP 200 when your user is over their limit. Read the body. `keys.verifyKey` sets `data.valid` to `false` and `data.code` to `RATE_LIMITED` (or `USAGE_EXCEEDED` when credits run out). Each checked limit is in `data.ratelimits`, with `limit`, `remaining`, `reset`, and `exceeded`. `ratelimit.limit` sets `data.success` to `false` and puts `limit`, `remaining`, and `reset` on `data`. `reset` is Unix milliseconds, when the current window ends.

Your server is the one that returns `429 Too Many Requests` to the caller. <a href="https://www.rfc-editor.org/rfc/rfc6585#section-4" target="_blank">RFC 6585</a> defines that status and says the response may include `Retry-After`, a number of seconds to wait. Set it from `reset`, and use at least 1 second. Unkey doesn't send `Retry-After` or `X-RateLimit-*` on these two endpoints for your user's limit, so copy `limit`, `remaining`, and `reset` onto your own response if you want those headers. `X-RateLimit-Reset` on the Compute gateway is Unix seconds, so divide `reset` by 1000 if you want to match it.

If the API runs on Compute, the gateway returns 429 itself. The response has `Retry-After` (seconds, never under 1), `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (Unix seconds). See [rate_limited](/errors/frontline/client/rate_limited).

Your server's own calls to `api.unkey.com` have a separate workspace limit. If you exceed it, Unkey returns HTTP 429 with `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` (seconds until the window ends), and `Retry-After`. Those headers describe your workspace's budget for the Unkey API, not your users' limits.

<Tooltip tip="Three Unkey features share this name: the standalone ratelimit API and key or identity limits (this section), and the Compute gateway rate limit policy. See the glossary.">Rate limiting</Tooltip> in API Management has two forms. A standalone API takes any identifier you choose. Limits on a key or identity are checked when the key is verified. The Compute gateway's rate limit policy is separate. See the [glossary](/platform/glossary).

## Choose a rate limit

| | Standalone `ratelimit.limit` | Key and identity rate limits |
| --- | --- | --- |
| What you limit | Any string: a user ID, an IP address, an email domain, a tenant | A key, or every key of an identity together |
| Where it runs | Its own endpoint, whenever you call it | Inside `keys.verifyKey`, as one of its checks |
| Configuration | You send the limit and duration with every call | Named limits stored on the key or identity |
| Per-caller exceptions | Overrides on an identifier or wildcard pattern | Different limits per key or identity |
| Failure signal | `success: false` in the response | `code: RATE_LIMITED` in the verification |

Use the standalone API when the endpoint has no key (sign-up, password reset) or when you want to limit something other than the key holder. Use key and identity limits when the request already carries a key, so the check happens during the verification you already make.

Both count requests the same way. See [How rate limiting works](/api-management/ratelimiting/how-it-works).

## A first rate limit

<RootKeyPrereq />

The root key needs `ratelimit.*.limit`, plus `ratelimit.*.create_namespace` the first time a namespace name is used.

```bash
curl -X POST https://api.unkey.com/v2/ratelimit.limit \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "namespace": "email.send",
    "identifier": "user_123",
    "limit": 10,
    "duration": 60000
  }'
```

```json
{
  "meta": { "requestId": "req_..." },
  "data": { "success": true, "limit": 10, "remaining": 9, "reset": 1704067260000 }
}
```

The namespace `email.send` names what you're limiting. The identifier `user_123` is who's being counted. Each user gets ten calls per minute, and the eleventh returns `success: false` until the window moves on. In the TypeScript SDK, the same call is `unkey.ratelimit.limit({ namespace, identifier, limit, duration })`.

## Next steps

<Columns cols={2}>
  <Card title="How rate limiting works" icon="wave-square" href="/api-management/ratelimiting/how-it-works">
    Sliding windows, regional counters, cross-region convergence, and what `remaining` and `reset` mean.
  </Card>
  <Card title="ratelimit.limit and multiLimit" icon="code" href="/api-management/ratelimiting/limit-and-multi-limit">
    Request and response fields, bounds, namespaces, cost, and atomic multi-limit checks.
  </Card>
  <Card title="Rate limit overrides" icon="sliders" href="/api-management/ratelimiting/overrides">
    Give one identifier or a wildcard pattern a different limit without changing code.
  </Card>
  <Card title="Key and identity rate limits" icon="key" href="/api-management/ratelimiting/key-and-identity-limits">
    Named limits on keys and identities, auto-apply, cost, and inline limits at verification.
  </Card>
</Columns>

---

### Api Management Sdks Overview (`docs/api-management/sdks/overview.mdx`)

---
title: SDKs
sidebarTitle: "Overview"
description: "Official libraries for TypeScript, Go, and Python, plus a community Nuxt module."
sources:
  - docs/snippets/versions.mdx
  - web/internal/api/src/sdk/sdk.ts
  - web/internal/api/src/models/components/v3deploymentscreatedeploymentrequestbody.ts
  - cmd/api/util/client.go
  - cmd/api/deployments/create_deployment.go
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/README.md
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/ts/README.md
  - https://github.com/unkeyed/sdks/blob/49e455e0e6a3204f57ef325054438cf1bd7d6586/api/go/README.md
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/py/README.md
  - https://www.npmjs.com/package/@unkey/nuxt
---

import { versions } from "/snippets/versions.mdx";
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

Use an SDK to call the Unkey API from your code. We publish clients for TypeScript, Go, and Python that cover every endpoint, plus smaller TypeScript packages for one job each, such as verifying keys in a Hono route. The community maintains a Nuxt module. Every package that calls the API needs a root key.

<RootKeyPrereq />

## Packages

| Package | Language | Current version | What it is |
| --- | --- | --- | --- |
| [@unkey/api](/api-management/sdks/typescript/api) | TypeScript | {versions.tsApi} | Generated client for the whole API |
| [@unkey/ratelimit](/api-management/sdks/typescript/ratelimit) | TypeScript | {versions.tsRatelimit} | Standalone rate limiting with timeouts and fallbacks |
| [@unkey/hono](/api-management/sdks/typescript/hono) | TypeScript | {versions.tsHono} | Hono middleware that verifies the bearer key |
| [@unkey/nextjs](/api-management/sdks/typescript/nextjs) | TypeScript | {versions.tsNextjs} | `withUnkey` wrapper for Next.js route handlers |
| [@unkey/cache](/api-management/sdks/typescript/cache) | TypeScript | {versions.tsCache} | Typed tiered cache with stale-while-revalidate |
| [Go SDK](/api-management/sdks/go) | Go | {versions.goSdk} | Generated client, module `github.com/unkeyed/sdks/api/go/v3` |
| [Python SDK](/api-management/sdks/python) | Python | {versions.pySdk} | Generated client `unkey.py`, sync and async |
| [Nuxt module](/api-management/sdks/community/nuxt) | TypeScript | {versions.tsNuxt} | Community-maintained Nuxt integration |

The TypeScript, Go, and Python clients match the [API reference](/api-management/api-reference/keys/verify-api-key), so method names and fields line up across languages. The Python client ({versions.pySdk}) covers API Management only. The TypeScript and Go clients also cover Compute.

## Which package to use

- **`@unkey/api`, the Go SDK, or the Python SDK** when you call several endpoints or want full types.
- **`@unkey/hono` or `@unkey/nextjs`** when you only need to check incoming requests for a valid key. They call `keys.verifyKey` and give your handler the result.
- **`@unkey/ratelimit`** to rate limit user IDs or IP addresses without issuing keys. It adds a timeout and fallback so a network problem can't block your function.
- **`@unkey/cache`** for typed caching in your own services. It doesn't call the Unkey API.

## One client, both products

The TypeScript and Go clients cover Compute too, so the same `Unkey` instance that <Tooltip tip="Here: checking an API key on a request with keys.verifyKey. Not domain verification.">verifies</Tooltip> a key can create a deployment for a Compute <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip>. The [Go SDK](/api-management/sdks/go) page shows the same pair in Go. From Python, call Compute endpoints over HTTP. The endpoints are [keys.verifyKey](/api-management/api-reference/keys/verify-api-key) and [deployments.createDeploymentV3](/compute/api-reference/deployments/create-deployment).

<CodeGroup>

```typescript Verify a key
import { Unkey } from "@unkey/api";

const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY ?? "" });

const result = await unkey.keys.verifyKey({ key: "prod_abc123" });

if (!result.data.valid) {
  // result.data.code says why: NOT_FOUND, DISABLED, EXPIRED, RATE_LIMITED, ...
}
```

```typescript Create a deployment
import { Unkey } from "@unkey/api";

const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY ?? "" });

const result = await unkey.deployments.createDeploymentV3({
  project: "my-project",
  app: "api",
  environment: "production",
  oci: { image: "ghcr.io/acme/api:v1.0.0" },
});

console.log(result.data.deploymentId);
```

</CodeGroup>

For Compute concepts such as projects, apps, and <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environments</Tooltip>, see [Projects, apps, and environments](/compute/concepts/projects-apps-environments).

## Response shape

Every successful response has `meta.requestId` (quote it when you contact support) and `data`, the result. Errors are thrown or returned as typed errors per HTTP status. Each SDK page shows how to catch them. See [API overview](/platform/api/overview) for the raw format.

## Next steps

<Columns cols={2}>
  <Card title="@unkey/api" icon="js" href="/api-management/sdks/typescript/api">
    Install, construct, verify and create keys, handle errors.
  </Card>
  <Card title="Go SDK" icon="golang" href="/api-management/sdks/go">
    `go get` the v3 module the CLI uses.
  </Card>
  <Card title="Python SDK" icon="python" href="/api-management/sdks/python">
    `unkey.py` with sync and async clients.
  </Card>
</Columns>

---

### Api Management Security Overview (`docs/api-management/security/overview.mdx`)

---
title: API key security
sidebarTitle: "Overview"
description: "How the keys you issue are protected, from storage to leak detection."
sources:
  - pkg/hash/sha256.go
  - internal/services/keys/create.go
  - svc/api/routes/v2_keys_verify_key/handler.go
  - svc/api/routes/v2_keys_create_key/handler.go
  - web/internal/db/src/schema/keyAuth.ts
  - web/internal/db/src/schema/util/delete_protection.ts
  - web/apps/dashboard/app/api/v1/github/verify/route.ts
  - pkg/rbac/permissions.go
---

Here's how the API keys you issue are protected, and what you can do to keep them safe. For workspace security, such as how your team signs in, see [Security](/platform/security/overview).

## Keys are stored as hashes

When you create a key, you get it back once and we keep only a hash. A copy of our database wouldn't give anyone a working key, and we can't show a key again. So show the key when it's created and let users roll it over. Don't promise to show it later. See [Key storage](/platform/security/key-storage).

## Recovery is opt-in and separately permissioned

If your product must show a key more than once, we can turn on encrypted storage for a keyspace. Keys created with `recoverable: true` are then also stored encrypted with keys unique to your workspace. It's off by default. Creating a recoverable key needs the `encrypt_key` permission, and reading one back needs `decrypt_key`, so you can grant recovery narrowly. See [Recovering keys](/api-management/security/recovering-keys) for the trade-offs and [Recoverable keys](/api-management/keys/recoverable-keys) for the steps.

## Verification doesn't leak

`keys.verifyKey` returns `NOT_FOUND` for several failures, including a root key without permission for the keyspace, so nobody can use it to find out which keys exist. Every verification is recorded in [analytics](/api-management/analytics/overview), and verifications made with a root key are also in the [audit log](/platform/workspace/audit-logs/overview).

## IP allow lists

A keyspace can have a list of IP addresses its keys can be verified from. Verification from anywhere else fails with `FORBIDDEN`. Use it for keys that should only be used from your own servers. See [Keyspace settings](/api-management/keyspaces/settings).

## Delete protection

With delete protection on, you can't delete a keyspace until you turn protection off, so one wrong call can't wipe out a production keyspace and all its keys. See [Delete protection](/platform/security/delete-protection).

## Root keys are scoped

A root key has only the permissions you give it, down to one keyspace. For example, `api.<api_id>.verify_key` lets a service verify keys in one keyspace and nothing else. Give each service its own root key with as few permissions as it needs, so a leaked key does little damage and is easy to replace. See [Root key permissions](/platform/root-keys/permissions).

## Leaked credentials

GitHub's secret scanning recognizes root keys by their `unkey_` prefix. If one is pushed to a public repository, every member of your workspace gets an email. The keys you issue to your users use your own prefixes, and GitHub doesn't scan for them, so delete or reroll a user's key yourself when you learn it leaked. Details are on [GitHub secret scanning](/platform/security/github-secret-scanning).

## Everything is audited

Every change to keyspaces, keys, identities, roles, permissions, and portals is in the audit log, with who did it and from where. You can stream it to your own systems. See [Audit logs](/platform/workspace/audit-logs/overview).

---

### Compute Build Overview (`docs/compute/build/overview.mdx`)

---
title: Builds
sidebarTitle: "Overview"
description: "Choose how your app is built, and work out why a build is waiting or has failed."
sources:
  - svc/ctrl/worker/deploy/deploy_handler.go
  - svc/ctrl/worker/deploy/build.go
  - svc/ctrl/worker/deploy/railpack.go
  - svc/api/openapi/spec/common/EnvironmentBuild.yaml
  - svc/api/openapi/spec/common/DeploymentErrorCode.yaml
  - svc/api/openapi/spec/paths/v2/environments/updateSettings/V2EnvironmentsUpdateSettingsRequestBody.yaml
  - web/internal/db/src/schema/app_build_settings.ts
  - web/apps/dashboard/lib/limits.ts
  - svc/ctrl/worker/deploy/build_handler.go
  - svc/ctrl/worker/cron/buildlimitsync/handler.go
---

A build turns the source of a git-connected <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip> into the container image your <Tooltip tip="One built and running version of an app in one environment.">deployment</Tooltip> runs. Builds happen on our infrastructure, so you don't need Docker installed to deploy.

Only git-connected apps build. If you deploy a prebuilt image, there is nothing to build: the deployment records the image you named and runs it, and none of the settings on this page apply.

## Choose how your app is built

There is one decision, and it is the Dockerfile path in your <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environment</Tooltip>'s [build settings](/compute/configure/build-settings).

Leave it empty, the default the dashboard calls **Automatic (no Dockerfile)**, and we detect your language and toolchain from the root directory and build it for you. Set a path and we run a normal Docker build with that file, using the root directory as the build context. See [Dockerfile builds](/compute/build/dockerfile).

To get an automatic build right:

- **Pin your tool versions** in the files your ecosystem already uses. Detection reads them to decide which toolchain to install, so a project that pins nothing can move versions underneath you between builds.
- **In a monorepo, set the root directory** to the app's own directory, so detection looks at the right project rather than the repository root.
- **Set a build command** when the detected one is wrong. It can be up to 1000 characters, and it is ignored when a Dockerfile is set.

If detection can't work out how to build your app, the deployment fails with "Unkey could not build this app automatically. For a monorepo, set the root directory to your app and a custom build command in settings, or review the build logs for details." The fix is one of the three above.

## Why your build is waiting

A workspace runs one build at a time on every plan today, so a second deployment sits in `pending` until the first finishes. Deployments from a prebuilt image queue too, even though they don't build.

Production deployments jump ahead of preview deployments in the queue, so a hot-fix doesn't wait behind a backlog of pull request builds.

A deployment that has waited an hour for its turn fails rather than waiting longer, so a jammed queue surfaces instead of hanging. Deploy again once the queue has drained.

## Why your build failed

A failed build ends the deployment as `failed` with the error code `build_failed`. Read the build output on the deployment's detail page in the dashboard, where it streams live and stays afterwards. Common causes are rewritten in plain language above the log, for example a Dockerfile that couldn't be read or a file the build referenced that doesn't exist, and the full log is always underneath.

Problems in your own source, such as a Dockerfile syntax error or a build command that exits non-zero, fail immediately. Retrying them without changing anything gives the same result.

Infrastructure blips are retried for you, up to five attempts and no longer than 30 minutes in total, whichever comes first. A build that takes unusually long and then fails has normally spent that time in retries.

## Make builds faster, or skip them

Build layers are cached per project, up to 25 GB, with layers older than 7 days evicted. Repeat builds of a project whose dependencies haven't changed reuse that cache. A project you haven't deployed in over a week builds from scratch again.

Because every deployment produces its own image, promoting and rolling back never rebuild. They run an image that already exists, which is why a rollback is fast.

Two settings stop a push from building at all:

- **Watch paths** builds only when a changed file matches one of the environment's glob patterns, so a documentation-only commit doesn't start a build.
- **Auto-deploy**, turned off, stops pushes from deploying entirely, leaving you to deploy from the dashboard or the API when you choose.

A push that isn't built still appears as a `skipped` deployment with the reason, so you can tell the difference between a push we ignored and one we never saw. Both settings live on [Build settings](/compute/configure/build-settings), and the order they're evaluated in is on [GitHub integration](/compute/build/github).

Your environment variables are available while the build runs without being baked into the image. See [Build-time secrets](/compute/build/build-secrets).

## Next steps

<Columns cols={2}>
  <Card title="Dockerfile builds" icon="file-code" href="/compute/build/dockerfile">
    Take control of the image when detection isn't enough.
  </Card>
  <Card title="Build-time secrets" icon="key" href="/compute/build/build-secrets">
    Use environment variables during the build without baking them into layers.
  </Card>
  <Card title="Build settings" icon="sliders" href="/compute/configure/build-settings">
    Root directory, Dockerfile, build command, watch paths, auto deploy.
  </Card>
  <Card title="Deployments" icon="rocket" href="/compute/concepts/deployments">
    Where the build sits in the deployment lifecycle.
  </Card>
</Columns>

---

### Compute Gateway Overview (`docs/compute/gateway/overview.mdx`)

---
title: Gateway
sidebarTitle: "Overview"
description: "What the gateway in front of every deployment does, and how policies control it."
sources:
  - svc/frontline/routes/proxy/handler.go
  - svc/frontline/internal/router/lookup.go
  - svc/frontline/internal/db/queries/ingress_route_find_by_fqdn.sql
  - svc/ctrl/worker/deploy/create.go
  - svc/frontline/internal/policies/engine.go
  - svc/frontline/internal/proxy/director.go
  - svc/frontline/internal/proxy/forward.go
  - svc/frontline/internal/proxy/headers.go
  - svc/frontline/middleware/sanitize_headers.go
  - svc/frontline/middleware/clickhouse_logging.go
  - pkg/timing/entry.go
  - svc/api/openapi/spec/common/Policy.yaml
---

import { ProductLink } from "/snippets/product-link.jsx";

Every <Tooltip tip="One built and running version of an app in one environment.">deployment</Tooltip> on Unkey Compute sits behind the gateway, and there's no other way to reach it. So the gateway can check API keys, <Tooltip tip="Here: a gateway rate limit policy or a per-key limit the gateway enforces. Not the standalone ratelimit API.">rate limit</Tooltip> callers, block requests, validate them against your OpenAPI spec, and log them before they reach your <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip>. You set this up with policies. With no policies, the gateway just passes requests through.

<Warning>
Each deployment keeps the policies it was created with. Adding, editing, or turning off a policy only affects the **next** deployment. To apply a change, redeploy (**Redeploy** in the dashboard, or `deployments.createDeployment`).
</Warning>

## What policies can do

A policy has a name, an on/off switch, up to ten match expressions that pick which requests it applies to, and one rule. There are five kinds of rule:

| Type | What it does | On rejection |
| --- | --- | --- |
| [API key authentication](/compute/gateway/api-key-auth) | Checks an Unkey API key on the request and tells your app who's calling with the [principal header](/compute/gateway/principal). | `401`, `403`, or `429` |
| [Rate limit](/compute/gateway/rate-limiting) | Counts requests per client IP, header, path, authenticated caller, or principal field, and rejects when the limit is used up. | `429` |
| [Firewall](/compute/gateway/firewall) | Blocks every request its match expressions select. | `403` |
| [OpenAPI validation](/compute/gateway/openapi-validation) | Checks the path, query, headers, and body against the OpenAPI spec your deployment serves. | `400` |
| [Logging](/compute/gateway/logging) | Saves headers, query data, and bodies of matched requests in the request log. | Never rejects |

See [Gateway policies](/compute/gateway/policies) to add them from the dashboard, API, or CLI.

## What happens to a request

1. The request arrives at one of your domains, and the gateway finds the deployment it belongs to.
2. The deployment's policies run in list order. Turned-off policies, and policies whose match expressions don't fit the request, are skipped.
3. If a policy rejects the request, the caller gets a `4xx` with an `err:frontline:...` code. The request never reaches your app and doesn't appear in your request log. See [Gateway errors](/compute/gateway/errors) for every code.
4. If every policy passes, the gateway forwards the request to your app with the headers below.

## What your app receives

The gateway keeps the original `Host` header and adds these:

| Header | Value |
| --- | --- |
| `X-Unkey-Request-Id` | The request ID. The caller sees the same value on the response and in error bodies. |
| `X-Unkey-Region` | The gateway's platform and region as `<platform>::<region>`. |
| `X-Unkey-Frontline-Id` | The ID of the gateway that handled the request. |
| `X-Unkey-Principal` | Who's calling, as JSON. Only present when an API key authentication policy succeeded. |
| `X-Forwarded-For` | The client IP. |
| `X-Forwarded-Host` | The hostname the client requested. |
| `X-Forwarded-Proto` | Always `https`. |

Callers can't fake these. We remove any `X-Unkey-*` header a client sends before policies run. To get the deployment ID, read the `UNKEY_DEPLOYMENT_ID` environment variable.

The response to the caller includes `X-Unkey-Request-Id`, `X-Unkey-Region`, and `X-Unkey-Frontline-Id`. Rate limit headers are added when a rate limit applied.

## Use keys from API Management

The API keys the gateway checks live in API Management. To require a key, create a keyspace and keys in API Management, then add the keyspace to an [API key authentication policy](/compute/gateway/api-key-auth). The same keys also work if your own code calls `keys.verifyKey`.

<ProductLink product="api-management" href="/api-management/keys/creating-keys" title="Keyspaces and keys">
  Create the keyspace and keys an authentication policy verifies against, and manage their permissions, rate limits, and credits.
</ProductLink>

## Next steps

<Columns cols={2}>
  <Card title="Gateway policies" icon="list-check" href="/compute/gateway/policies">
    Match expressions, evaluation order, limits, and how to set policies.
  </Card>
  <Card title="API key authentication policy" icon="key" href="/compute/gateway/api-key-auth">
    Verify Unkey keys at the edge and forward the identity to your app.
  </Card>
  <Card title="The principal header" icon="id-badge" href="/compute/gateway/principal">
    The JSON your app reads to know who is calling.
  </Card>
  <Card title="Gateway errors" icon="triangle-exclamation" href="/compute/gateway/errors">
    Every code the gateway returns, with its status and cause.
  </Card>
</Columns>

---

### Compute Observe Overview (`docs/compute/observe/overview.mdx`)

---
title: Observability
sidebarTitle: "Overview"
description: "The signals Unkey records for a running app and where to find each one."
sources:
  - web/apps/dashboard/lib/navigation/leaves.ts
  - svc/frontline/middleware/clickhouse_logging.go
  - svc/frontline/routes/proxy/handler.go
  - pkg/clickhouse/schema/022_frontline_requests_raw_v1.sql
  - pkg/clickhouse/schema/036_frontline_requests_per_minute_v1.sql
  - pkg/clickhouse/schema/037_frontline_requests_per_5m_v1.sql
  - pkg/clickhouse/schema/038_frontline_requests_per_15m_v1.sql
  - pkg/clickhouse/schema/039_frontline_requests_per_hour_v1.sql
  - pkg/clickhouse/schema/040_frontline_requests_per_day_v1.sql
  - pkg/clickhouse/schema/023_runtime_logs_raw_v1.sql
  - pkg/clickhouse/schema/020_build_steps_v1.sql
  - pkg/clickhouse/schema/021_build_step_logs_v1.sql
  - pkg/clickhouse/schema/025_instance_checkpoints_v1.sql
  - pkg/clickhouse/schema/027_instance_resources_per_minute_v1.sql
  - pkg/clickhouse/schema/028_instance_resources_per_hour_v1.sql
  - pkg/clickhouse/schema/033_instance_resources_per_15s_v1.sql
  - pkg/clickhouse/schema/034_instance_resources_per_day_v1.sql
  - pkg/clickhouse/schema/035_instance_resources_per_month_v1.sql
  - pkg/clickhouse/schema/029_instance_events_raw_v1.sql
  - pkg/clickhouse/user.go
  - internal/services/analytics/executor.go
  - web/apps/dashboard/lib/limits.ts
---

We record four kinds of data about your <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip>, with nothing to install in your code:

- Every request the gateway passes to your app.
- Everything your instances print to stdout and stderr.
- CPU, memory, disk, and network for each instance.
- Each build step and its output.

<Columns cols={2}>
  <Card title="Request logs" icon="arrow-right-arrow-left" href="/compute/observe/requests">
    One row per proxied request: method, path, status, and the split between gateway and instance latency. Headers and bodies on request.
  </Card>
  <Card title="Runtime logs" icon="terminal" href="/compute/observe/runtime-logs">
    stdout and stderr from every instance, parsed into severity, message, and searchable attributes.
  </Card>
  <Card title="Metrics" icon="chart-line" href="/compute/observe/metrics">
    Requests per second, latency percentiles, CPU, memory, disk, network, instance count, and instance events.
  </Card>
  <Card title="Build logs" icon="hammer" href="/compute/observe/build-logs">
    Each step of a build, whether it was cached, and its output.
  </Card>
</Columns>

## Where to look in the dashboard

- **Request and runtime logs:** open a project and click **Requests** or **Logs** in the sidebar. Filter by app, <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environment</Tooltip>, <Tooltip tip="One built and running version of an app in one environment.">deployment</Tooltip>, or region.
- **Metrics and build steps:** open the app, click **Deployments**, and open a deployment. Its overview has the traffic and resource charts and the build steps. Its **Network** view shows regions and instances with live request rates.

## Rejected requests aren't in the request log

The request log only has requests that reached your app. Requests rejected by a policy, or that the gateway couldn't route (an unknown hostname, or no running instance), aren't there. So your request log can show less traffic than your callers sent. See [Request logs](/compute/observe/requests).

## Retention

Each kind of data is kept for a fixed time, shown below. The dashboard can show all of it.

The [analytics API](/compute/observe/analytics-api) has a second limit: your plan's log query range, 3 days on Starter, 7 on Pro, and 14 on Business. You can read back whichever is shorter. A query that reaches further fails with [`err:user:bad_request:query_range_exceeds_retention`](/errors/user/bad_request/query_range_exceeds_retention) instead of returning partial results.

| Data | Stored for | Queryable |
| --- | --- | --- |
| Request log rows | 7 days | Dashboard: 7 days. Analytics API: the shorter of 7 days and your plan's range |
| Per-minute request counts and latency percentiles | 14 days | Charts, no plan range |
| 5 and 15 minute request aggregates | 30 days | Charts, no plan range |
| Hourly request aggregates | 90 days | Charts, no plan range |
| Daily request aggregates | 365 days | Charts, no plan range |
| Runtime logs | 90 days from ingestion | Dashboard: 90 days. Analytics API: the shorter of 90 days and your plan's range |
| Build steps and build logs | 3 months | Dashboard only |
| 15 second resource metrics | 7 days | Charts, no plan range |
| Per-minute resource metrics | 30 days | Charts, no plan range |
| Hourly resource metrics | 90 days | Charts, no plan range |
| Daily resource metrics | 365 days | Charts, no plan range |
| Monthly resource metrics | 1825 days | Charts, no plan range |
| Raw resource samples | 95 days | Charts, no plan range |
| Instance events | 90 days | Charts, no plan range |

For example, on Starter the analytics API reaches back 3 days into runtime logs that are kept for 90. On Business it reaches 7 days of request logs, not 14, because that's how long they're kept. See [Compute limits](/compute/configure/limits).

## Query the data yourself

You can query request and runtime logs with SQL through the analytics API, using the `gateway_requests_v1` and `runtime_logs_v1` tables, to build your own dashboards or alerts. See [Query gateway requests and runtime logs](/compute/observe/analytics-api). Read build logs through the API with `deployments.listBuildLogs`. See [Build logs](/compute/observe/build-logs). Resource metrics are only in the dashboard.

---

### Platform Ai Coding Overview (`docs/platform/ai-coding/overview.mdx`)

---
title: AI coding
sidebarTitle: "Overview"
description: "Connect your AI coding assistant to Unkey so it can manage your workspace and read the docs."
sources:
  - docs/docs.json
  - https://mcp.unkey.com/mcp/v2/install
  - https://mcp.unkey.com/mcp/compute/install
---

You can connect an AI coding assistant like Claude Code, Cursor, or VS Code to Unkey in two ways. It can act on your workspace through Unkey's MCP servers, and it can read these docs while it writes code for you.

## Let your assistant act on Unkey

Unkey hosts two MCP servers. Add one to your assistant with a root key, and it can call the Unkey API for you.

- **Unkey**: every v2 API endpoint, so keys, keyspaces, identities, permissions, rate limits, analytics, portals, and Compute.
- **Compute**: only the Compute endpoints, for projects, apps, environments, deployments, domains, and gateway policies.

See [Unkey MCP servers](/platform/ai-coding/mcp-servers) for the URLs, the root key to use, and example prompts.

## Give your assistant the docs

- **Docs MCP server**: lets your assistant search these docs while it works. Its URL is below. You can also add it from the menu next to **Copy page** at the top of any page, which has **Connect to Cursor**, **Connect to VS Code**, **Copy MCP Server**, and **Copy MCP install command**.
- **Markdown**: **Copy page** copies the page as Markdown you can paste into a chat. Add `.md` to the end of any docs URL to get the same thing as plain text.
- **llms.txt**: an index of every page, built for language models. `llms-full.txt` has the full text of the docs in one file.

```text
https://www.unkey.com/docs/mcp
https://www.unkey.com/docs/llms.txt
https://www.unkey.com/docs/llms-full.txt
```

## Use the CLI

Assistants that run terminal commands can also use the `unkey` CLI. Pass `--output=json` so they get the full API response. See [Install the CLI](/platform/cli/install).

## Next steps

<Columns cols={2}>
  <Card title="Unkey MCP servers" icon="plug" href="/platform/ai-coding/mcp-servers">
    Pick a server, create a root key for it, and try some prompts.
  </Card>
  <Card title="Claude Code" icon="terminal" href="/platform/ai-coding/claude-code">
    Add Unkey to Claude Code with one command.
  </Card>
  <Card title="Cursor" icon="arrow-pointer" href="/platform/ai-coding/cursor">
    Add Unkey to Cursor's `mcp.json`.
  </Card>
  <Card title="Other clients" icon="grid-2" href="/platform/ai-coding/other-clients">
    VS Code, Claude Desktop, Codex CLI, and any other MCP client.
  </Card>
</Columns>

---

### Platform Api Overview (`docs/platform/api/overview.mdx`)

---
title: HTTP API basics
sidebarTitle: "Overview"
description: "Call the Unkey API: one base URL, POST endpoints, and a shared response envelope."
sources:
  - svc/api/openapi/openapi-generated.yaml
  - svc/api/openapi/spec/common/Meta.yaml
  - svc/api/openapi/spec/common/Pagination.yaml
  - svc/api/openapi/spec/paths/v2/apis/listKeys/V2ApisListKeysRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/apis/listKeys/V2ApisListKeysResponseBody.yaml
  - svc/api/internal/pagination/pagination.go
  - svc/api/routes/v2_ratelimit_list_overrides/handler.go
  - pkg/zen/auth.go
---

Compute and API Management share one HTTP API. Here's how to make a request, read the response, and page through lists.

## Make a request

Send a `POST` with a JSON body to `https://api.unkey.com/v2/{service}.{procedure}`. There are no path or query parameters. The only `GET` routes are `/v2/liveness`, the health check, and `/openapi.yaml` and `/reference`, which serve the OpenAPI document and a browsable reference. See [RPC naming convention](/platform/api/rpc-convention) for how endpoints are named.

```bash
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
  -H "Authorization: Bearer <root key>" \
  -H "Content-Type: application/json" \
  -d '{"key": "sk_live_..."}'
```

Put a root key in the `Authorization: Bearer` header. See [API authentication](/platform/api/authentication).

## Read the response

Every response is a JSON object with `meta`. A success adds `data`, and a failure adds `error` instead.

<ResponseField name="meta" type="object" required>
  Present on every response.
  <Expandable title="properties">
    <ResponseField name="requestId" type="string" required>
      A unique ID for this request, prefixed `req_`. It's the only field in `meta`, and it isn't sent as a response header. Log it with every call and include it when you contact support, so we can find the exact request.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="data" type="object | array">
  The endpoint's result. Its shape is documented per endpoint in each product's API reference. List endpoints return an array here.
</ResponseField>

<ResponseField name="error" type="object">
  Present instead of `data` when the request failed. Its fields are on [API error envelope](/platform/api/errors).
</ResponseField>

<ResponseField name="pagination" type="object">
  Present only on list endpoints. See [Page through lists](#page-through-lists).
</ResponseField>

```json A successful response
{
  "meta": { "requestId": "req_2c9a0jf23l4k567" },
  "data": { "keyId": "key_1234abcd" }
}
```

## Page through lists

List endpoints use a cursor instead of page numbers. Each request takes an optional `limit` and `cursor`, and each response has a `pagination` object next to `data`. The example uses `apis.listKeys`, but every list endpoint works the same way.

<ParamField body="limit" type="integer">
  Maximum items to return. For `apis.listKeys` it's 1 to 100, default 100. Most list endpoints default to 100, but `ratelimit.listOverrides` defaults to 50. Send `limit` if the page size matters to you.
</ParamField>

<ParamField body="cursor" type="string">
  The `pagination.cursor` value from the previous response, sent back unchanged. Leave it out for the first page. Cursors can expire, so don't store them long term or build them yourself.
</ParamField>

<ResponseField name="pagination.hasMore" type="boolean" required>
  `true` when there's another page. When `false`, you have everything and `cursor` may be missing or `null`.
</ResponseField>

<ResponseField name="pagination.cursor" type="string">
  The value to send as `cursor` on the next request. Only use it while `hasMore` is `true`.
</ResponseField>

<Steps titleSize="h3">
  <Step title="Request the first page">
    ```bash
    curl -X POST https://api.unkey.com/v2/apis.listKeys \
      -H "Authorization: Bearer <root key>" \
      -H "Content-Type: application/json" \
      -d '{"apiId": "api_1234abcd", "limit": 50}'
    ```
  </Step>
  <Step title="Read the cursor">
    ```json
    {
      "meta": { "requestId": "req_5678efgh" },
      "data": [ { "keyId": "key_1111aaaa" }, { "keyId": "key_2222bbbb" } ],
      "pagination": { "cursor": "key_3333cccc", "hasMore": true }
    }
    ```
  </Step>
  <Step title="Send it back until hasMore is false">
    ```bash
    curl -X POST https://api.unkey.com/v2/apis.listKeys \
      -H "Authorization: Bearer <root key>" \
      -H "Content-Type: application/json" \
      -d '{"apiId": "api_1234abcd", "limit": 50, "cursor": "key_3333cccc"}'
    ```
  </Step>
</Steps>

Keep the other request fields the same on every page. If you change a filter partway through, the cursor no longer works.

---

### Platform Cli Overview (`docs/platform/cli/overview.mdx`)

---
title: The unkey CLI
sidebarTitle: "Overview"
description: "Run Unkey from your terminal with the unkey CLI and find the page for each command."
sources:
  - build/cli/main.go
  - cmd/api/root.go
  - cmd/api/util/flags.go
  - cmd/api/analytics/root.go
  - cmd/auth/main.go
  - cmd/deploy/main.go
  - cmd/healthcheck/main.go
  - pkg/cli/parser.go
  - .goreleaser.yaml
---

The `unkey` CLI lets you do from your terminal what you'd do with the API. It covers both products. Nearly every API endpoint has a matching `unkey api <group> <command>`. The exception is the customer portal's end-user endpoints, which use a portal session instead of a root key.

There are also three top-level commands: `unkey deploy` deploys a prebuilt container image to Compute, `unkey auth login` stores a root key so you don't pass it on every call, and `unkey healthcheck` checks a URL from a script.

<Warning>
  The CLI is early and best effort. Commands, flags, and output can change without notice. The HTTP API is versioned and stable, so use the API for scripts that must not break.
</Warning>

## Command shape

Commands look like `unkey <command> [subcommand] [flags] [arguments]`. `unkey --help`, `unkey <command> --help`, and `unkey help <command>` print usage, and `unkey --version` prints the installed version.

Every `unkey api` command shares five flags for auth, output, and a raw request body. They're on [CLI output and shared flags](/platform/cli/output-and-flags), along with flag syntax, so each command page only lists its own flags.

## Command groups

`unkey api` has 14 groups, one per API service. Each command has its own page in the CLI section of the product it belongs to.

### Compute

<Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">Apps</Tooltip>, deployments, domains, <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environments</Tooltip>, gateway policies, and the GitHub App installation are Compute resources.

| Group | What it manages |
| --- | --- |
| [`unkey api projects`](/compute/cli/projects/create-project) | Create, read, update, and delete workspace projects |
| [`unkey api apps`](/compute/cli/apps/create-app) | Create, read, update, and delete apps within projects |
| [`unkey api environments`](/compute/cli/environments/list-environments) | Environment variables and settings |
| [`unkey api deployments`](/compute/cli/deployments/create-deployment) | Create and control deployments |
| [`unkey api domains`](/compute/cli/domains/create-domain) | Create, read, <Tooltip tip="Here: proving DNS ownership of a custom domain. Not key verification.">verify</Tooltip>, and delete custom domains |
| [`unkey api gateway`](/compute/cli/gateway/list-policies) | List, replace, and update gateway policies |
| [`unkey api github`](/compute/cli/github/install-app) | GitHub App installations |
| `unkey api analytics` | [`get-gateway-requests`](/compute/cli/analytics/get-gateway-requests) and [`get-runtime-logs`](/compute/cli/analytics/get-runtime-logs) query gateway and runtime data |

### API Management

Keyspaces, keys, identities, permissions and roles, <Tooltip tip="Here: the standalone rate limiting API and its overrides. Not key rate limits or gateway policies.">rate limiting</Tooltip>, and the customer portal are API Management resources.

| Group | What it manages |
| --- | --- |
| [`unkey api apis`](/api-management/cli/apis/create-api) | Keyspaces (the API namespaces keys belong to) |
| [`unkey api keys`](/api-management/cli/keys/create-key) | Create, <Tooltip tip="Here: checking an API key on a request. Not domain verification.">verify</Tooltip>, update, and delete keys and their permissions and roles |
| [`unkey api identities`](/api-management/cli/identities/create-identity) | Identities that group keys |
| [`unkey api permissions`](/api-management/cli/permissions/create-permission) | Permissions and roles |
| [`unkey api ratelimit`](/api-management/cli/ratelimit/limit) | Rate limit checks and overrides |
| [`unkey api portal`](/api-management/cli/portal/create-session) | Customer portals and their sessions |
| `unkey api analytics` | [`get-verifications`](/api-management/cli/analytics/get-verifications) and [`get-ratelimits`](/api-management/cli/analytics/get-ratelimits) query key and rate limit data |

`analytics` appears in both tables because two of its commands read Compute data and two read API Management data.

## Top-level commands

| Command | Product | What it does |
| --- | --- | --- |
| `unkey deploy <image>` | Compute | Deploys a prebuilt container image to a project and waits until the deployment is active. Needs `--project` (or `UNKEY_PROJECT`), and a root key from `--root-key` or `UNKEY_ROOT_KEY`. It doesn't read the stored key. Reference: [unkey deploy](/compute/cli/deploy). |
| `unkey auth login` | Platform | Prompts for a root key and stores it in `~/.unkey/config.toml`. Reference: [unkey auth login](/platform/cli/auth/login). |
| `unkey healthcheck <url>` | Compute | Sends one HTTP GET and exits 0 on a 200 response, or 1 otherwise. Useful in CI and monitoring scripts. Reference: [unkey healthcheck](/compute/cli/healthcheck). |

## Next steps

<Columns cols={2}>
  <Card title="Install the CLI" icon="download" href="/platform/cli/install">
    npm package or GitHub release.
  </Card>
  <Card title="CLI authentication" icon="key" href="/platform/cli/authentication">
    Where the root key comes from and in which order.
  </Card>
  <Card title="Output and shared flags" icon="terminal" href="/platform/cli/output-and-flags">
    `--output json`, `--body`, `--api-url`, `--config`.
  </Card>
</Columns>

---

### Platform Root Keys Overview (`docs/platform/root-keys/overview.mdx`)

---
title: Root keys
sidebarTitle: "Overview"
description: "Create and rotate the root keys that authenticate you to the Unkey API."
sources:
  - svc/api/routes/v2_root_keys_create_key/handler.go
  - svc/api/routes/v2_root_keys_update_key/handler.go
  - svc/api/routes/v2_root_keys_delete_key/handler.go
  - web/apps/dashboard/app/(app)/[workspaceSlug]/root-keys/page.tsx
  - web/apps/dashboard/lib/trpc/routers/key/reroll/index.ts
  - web/apps/dashboard/lib/trpc/routers/settings/root-keys/delete.ts
  - web/apps/dashboard/components/api-keys-table/components/actions/components/rotate-key/rotate-key.constants.ts
  - web/internal/keys/src/util.ts
  - internal/services/caches/caches.go
---

import { DashboardScreenshot } from "/snippets/dashboard-screenshot.jsx";

A root key is the credential your services, CI jobs, and agents use to call Unkey through the API or CLI. It belongs to one workspace and has no permissions by default. You assign permissions to let it perform its task, such as deploying an app or verifying customer API keys.

Start with [Root key permissions](/platform/root-keys/permissions) to decide what access the key needs. Then use [Configure root keys](/platform/root-keys/configure) to create it in the dashboard or API. This page explains how to store, rotate, and delete it.

A root key isn't one of the API keys you issue to your own users. You create and verify those through the API, using a root key. See [API Management concepts](/api-management/get-started/concepts).

Manage root keys on the workspace's **Root Keys** page. You need the [admin role](/platform/workspace/team) to manage them in the dashboard. Automation can manage root keys through the API with the required [root key permissions](/platform/root-keys/permission-reference#root-key-operations).

<Frame>
  <DashboardScreenshot
    target="root-key-list"
    capture="target"
    description="Open Root Keys in a disposable local workspace with synthetic
      billing-staging-ci, billing-staging-debugger, and customer-key-verifier
      keys. Show the list with masked secrets and no open menus at 1440 by 450
      CSS pixels, scale 2, in both themes. Crop to the page content, keeping
      the header and all table columns. Never show a full secret."
    src="/platform/root-keys/root-key-list"
    capturedAt="2026-10-02T14:11:28Z"
  >
    <img className="block dark:hidden" src="/platform/root-keys/root-key-list-light.png" alt="Root key list with separate keys for CI, debugging, and API key verification" />
    <img className="hidden dark:block" src="/platform/root-keys/root-key-list-dark.png" alt="Root key list with separate keys for CI, debugging, and API key verification" />
  </DashboardScreenshot>
</Frame>

## What a root key looks like

A root key starts with `unkey_`, followed by random characters. It's shown to you only once (see [Key storage](/platform/security/key-storage)). The bearer key identifies the workspace for API requests. Permission URNs also include that workspace's ID.

A root key has no credit limit or rate limit of its own. You can set an expiry when creating it through the API. Use short-lived keys for temporary jobs and agents.

## Create a root key

Give each service, job, or agent its own root key. Choose the resource scope and actions in [Root key permissions](/platform/root-keys/permissions), then follow [Configure root keys](/platform/root-keys/configure) to create it in the dashboard or API.

Store the secret in your secret manager. You can't retrieve it again. The audit log records who created the key and which permissions it received.

## Edit a root key

From the key's row menu, choose **Edit root key** to change its name or permissions. Saving replaces the complete permission set, so review all policies before saving. See [Edit permissions](/platform/root-keys/configure#edit-permissions-in-the-dashboard) for dashboard and API instructions.

## Rotate a root key

Rotating gives you a new root key with the same name and permissions, and stops the old one after a grace period.

1. Choose **Rotate root key** from the row menu.
2. Pick a grace period: revoke immediately, 15 minutes, 1 hour, 6 hours, or 24 hours.
3. Check the confirmation, click **Rotate root key**, then confirm the rotation.
4. Copy the new key. It's shown once, like a new key.
5. Update your services before the grace period ends.

<Frame>
  <DashboardScreenshot
    target="root-key-rotate"
    capture="target"
    description="Open Rotate root key for the synthetic billing-staging-ci
      key. Select a 1 hour grace period and leave confirmation unchecked.
      Do not submit rotation. Capture both themes with transparent padding
      of 24 CSS pixels and no outer shadow at scale 2."
    src="/platform/root-keys/root-key-rotate"
    width={648}
    capturedAt="2026-10-02T14:02:40Z"
  >
    <img className="block dark:hidden" src="/platform/root-keys/root-key-rotate-light.png" alt="Root key rotation dialog with a one-hour grace period" />
    <img className="hidden dark:block" src="/platform/root-keys/root-key-rotate-dark.png" alt="Root key rotation dialog with a one-hour grace period" />
  </DashboardScreenshot>
</Frame>

When the grace period ends, requests with the old key fail with `err:unkey:authorization:forbidden`. If the old key was already set to expire sooner, it stops at that earlier time.

Rotate on a schedule that fits your security policy, and right away if a key has been exposed. With "revoke immediately", the old key stops working as soon as the new one exists.

## Delete a root key

Choose **Delete root key** from the row menu and confirm. You can't undo this. The key can no longer authenticate after the deletion propagates. The audit log records the deletion. Delete keys for services you've retired instead of leaving them active.

<Frame>
  <DashboardScreenshot
    target="root-key-delete"
    capture="target"
    description="Open the delete dialog for the synthetic billing-staging-debugger
      key. Leave the confirmation unchecked and Delete key disabled. Do not
      delete the key. Capture both themes at scale 2 with transparent padding
      of 24 CSS pixels and no outer shadow."
    src="/platform/root-keys/root-key-delete"
    width={608}
    capturedAt="2026-10-02T14:01:41Z"
  >
    <img className="block dark:hidden" src="/platform/root-keys/root-key-delete-light.png" alt="Root key deletion dialog with its confirmation unchecked" />
    <img className="hidden dark:block" src="/platform/root-keys/root-key-delete-dark.png" alt="Root key deletion dialog with its confirmation unchecked" />
  </DashboardScreenshot>
</Frame>

## If a root key leaks

Rotate it with "revoke immediately" or delete it, then remove the secret from wherever it was exposed. The key keeps working until you do.

If a root key is pushed to a public GitHub repository, [GitHub secret scanning](/platform/security/github-secret-scanning) reports it and every member of the workspace gets an email saying where it was found.

Unkey stores root keys hashed, so it can't recover the secret, only identify which key ID a request used. That's another reason to create one key per service.

## Permissions reference

<Columns cols={2}>
  <Card title="Root key permissions" icon="shield-halved" href="/platform/root-keys/permissions">
    Choose least-privilege permissions for deployments, agents, and API management.
  </Card>
  <Card title="Permission reference" icon="list" href="/platform/root-keys/permission-reference">
    Resource paths, supported actions, and operation requirements.
  </Card>
</Columns>

---

### Platform Security Overview (`docs/platform/security/overview.mdx`)

---
title: Security
sidebarTitle: "Overview"
description: "See how Unkey protects your keys and workspace and what you can turn on."
sources:
  - pkg/hash/sha256.go
  - pkg/encryption/aes.go
  - svc/api/routes/v2_keys_create_key/handler.go
  - svc/vault/internal/vault/rpc_encrypt.go
  - internal/services/keys/create_v1.go
  - web/apps/dashboard/lib/auth/roles.ts
  - web/apps/dashboard/lib/trpc/trpc.ts
  - web/apps/dashboard/lib/trpc/routers/workspace/changeName.ts
  - web/apps/dashboard/lib/trpc/routers/api/updateDeleteProtection.ts
  - web/internal/db/src/schema/util/delete_protection.ts
  - SECURITY.md
---

Unkey stores every API key and root key as a SHA-256 hash, so you can issue and verify keys without keeping a homegrown key table. A copy of the database isn't a list of working keys. This page covers that storage and what you can turn on around it.

## How Unkey stores API keys

Unkey doesn't store the API key. On create, it hashes the full key string with SHA-256 and stores the hash as standard base64, not hex. Verification hashes the key your user sent and compares the two strings. There's no salt. The plaintext is returned once, in the create response, and isn't kept.

See the <a href="https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html" target="_blank">OWASP Secrets Management Cheat Sheet</a> and the <a href="https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html" target="_blank">OWASP REST Security Cheat Sheet</a> for hashing and storing secrets you only need to verify. SHA-256 here is a fast hash of a random key, not a password hash such as bcrypt or Argon2.

If a keyspace must show a key again, you opt in to an encrypted copy beside the hash. The copy is AES-256-GCM, the keyring is the workspace, and only a root key with decrypt permission can read the key back. Verification still uses the hash. See [Key storage](/platform/security/key-storage) and [Recovering keys](/api-management/security/recovering-keys).

## Should you build your own API key system?

You can. What moves to Unkey is generating the random key, storing only the hash, and checking it on each request. `keys.createKey` issues the key. `keys.verifyKey` checks enabled, expiry, credits, rate limits, permissions, and the IP allow list. If you already have hashes and the format matches, [Migrating keys](/api-management/keys/migrating-keys) covers importing them.

You still run your API, unless you deploy it on Compute. You still decide the HTTP status you return to your users. You stop maintaining the keys table, the hash comparison, and, if you use Unkey for them, the rate-limit counters and the monthly credit balance.

## How Unkey protects the workspace

Two controls apply to the workspace itself, separate from how keys are stored.

**Access is scoped.** A root key only works in the workspace that issued it, and every endpoint needs a specific permission. Dashboard users are either an admin or a developer. A developer who tries an admin-only action gets `This action requires admin privileges.` See [Team](/platform/workspace/team).

**Changes are audited.** Changes made in the dashboard appear in the audit log with who made them, the event, and a description. For example, renaming a workspace logs `workspace.update`. How long entries are kept depends on your plan. See [Limits](/platform/billing/limits).

## Security features you can use

<Columns cols={2}>
  <Card title="Key storage" icon="lock" href="/platform/security/key-storage">
    Hashing, verification, and the opt-in encrypted storage for recoverable keys.
  </Card>
  <Card title="GitHub secret scanning" icon="github" href="/platform/security/github-secret-scanning">
    What happens when a key is pushed to a public repository.
  </Card>
  <Card title="Delete protection" icon="shield-halved" href="/platform/security/delete-protection">
    A flag that blocks deleting a keyspace, project, or app until you turn it off.
  </Card>
  <Card title="Two-factor authentication" icon="mobile-screen" href="/platform/workspace/two-factor-auth">
    Enroll a second factor on your own account.
  </Card>
</Columns>

## Reporting a vulnerability

If you find a security issue, email [security@unkey.com](mailto:security@unkey.com) with steps to reproduce. Don't open a public issue or pull request. Reports about `api.unkey.com` and `app.unkey.com` are in scope. The docs site, the marketing site, and misconfigured self-hosted setups aren't. The full policy is in [SECURITY.md](https://github.com/unkeyed/unkey/blob/main/SECURITY.md).

---

### Platform Workspace Overview (`docs/platform/workspace/overview.mdx`)

---
title: Workspaces
sidebarTitle: "Overview"
description: "Understand what a workspace holds and how to create or switch between them."
sources:
  - web/internal/db/src/schema/workspaces.ts
  - web/internal/id/src/generate.ts
  - web/apps/dashboard/lib/trpc/routers/workspace/create.ts
  - web/apps/dashboard/app/new/hooks/use-workspace-step.tsx
  - web/apps/dashboard/lib/slugify.ts
  - web/apps/dashboard/lib/limits.ts
  - web/apps/dashboard/lib/support.ts
---

A workspace is the top-level container. Everything you create sits in exactly one. Whether you deploy an <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip> with Compute or only issue API keys, it's the same workspace with different parts in use.

Most teams need one workspace. Make a second when you need hard isolation: separate billing for two business units, or a client whose data must never sit next to yours.

## What sits in a workspace

Everything on this site is scoped to a workspace: the [team](/platform/workspace/team) and roles, [billing plans](/platform/billing/plans) and [limits](/platform/billing/limits), root keys, keyspaces and keys, and Compute projects with their apps and <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environments</Tooltip>. A root key from one workspace can't touch another.

## Slug and ID

A workspace has two identifiers. They do different jobs.

<ParamField path="slug" type="string" required>
  The URL handle you choose when creating the workspace. It appears in every dashboard URL as `app.unkey.com/<slug>/...`, must be unique across Unkey, and can't be changed later. It's 3 to 64 characters of lowercase letters, digits, and hyphens, and can't start or end with a hyphen. The dashboard derives a slug from the workspace name as you type and lets you edit it before creating.
</ParamField>

<ParamField path="id" type="string">
  A generated identifier prefixed with `ws_`. Use it when an API call or a support request needs to name the workspace. Copy it from **Settings > General**. See [Workspace settings](/platform/workspace/settings).
</ParamField>

## Create a workspace

<Steps titleSize="h3">
  <Step title="Open the creation page">
    Go to [app.unkey.com/new](https://app.unkey.com/new). Signing in with a new account lands you here automatically.
  </Step>
  <Step title="Name the workspace">
    Enter a name between 3 and 50 characters. The name isn't shown to your users, so choose one your team recognizes.
  </Step>
  <Step title="Confirm the slug">
    The dashboard fills in a slug derived from the name. Edit it if you want a shorter handle. If the slug is already taken, the form tells you and you pick another.

    <Frame>
      <img src="/images/dashboard/platform--workspace-overview--create-workspace.png" alt="Create Company Workspace form with a workspace name entered and the URL handle filled in from it" />
    </Frame>
  </Step>
  <Step title="Create">
    Click **Create workspace**. The workspace starts on the free tier with the [free limits](/platform/billing/limits) and no Compute plan. You can deploy once you pick one under [Plans](/platform/billing/plans).
  </Step>
</Steps>

## Delete a workspace

The dashboard and the API don't offer workspace deletion. To close a workspace, email [support@unkey.com](mailto:support@unkey.com) from an address that belongs to a workspace admin. Make sure you cancel any paid plans under **Settings > Billing** so nothing renews while the request is handled.

---

### Scripts Brands README (`docs/scripts/brands/README.md`)

# Illustration brand marks

These marks identify integrations, not endorsements. Keep their original geometry and proportions. Full-color marks retain their source colors; monochrome marks use a light or dark treatment for contrast.

| Mark | Source |
| --- | --- |
| GitHub | [Official brand toolkit](https://brand.github.com/foundations/logo), `GitHub_Logos.zip`, `GitHub_Invertocat_Black.svg` |
| Unkey, Docker, TypeScript, Go, Python | Existing `web/internal/icons/src/icons/` assets |
| Hono | [Official logo](https://hono.dev/images/logo.svg) |
| Bun | [Official logo](https://bun.sh/logo.svg) |
| Next.js | [Official website asset](https://nextjs.org/_next/static/immutable/media/nextjs-logotype-light.00-_80jv8_dct.svg) |
| FastAPI | [Official icon](https://fastapi.tiangolo.com/img/icon-white.svg) |
| Express | [Official favicon](https://expressjs.com/favicon.svg) |
| Echo | [Official favicon](https://echo.labstack.com/favicon.svg) |
| Cloudflare | [Official website asset](https://cf-assets.www.cloudflare.com/dzlvafdwdttg/735eoClKJf9XfkqCJs1mfZ/b6767158f39af8d538517df918b8fc2e/logo-white-desktop.svg) |

The artwork generator embeds these local vectors, so card rendering does not depend on third-party requests. Cloudflare uses the standalone cloud symbol paths from its horizontal logo. Source files are retained here to make provenance and future brand updates reviewable.

---

### Platform Workspace Audit Logs Overview (`docs/platform/workspace/audit-logs/overview.mdx`)

---
title: Audit logs
sidebarTitle: "Overview"
description: "Who changed what in your workspace, and how long the record is kept."
sources:
  - pkg/auditlog/log.go
  - pkg/auditlog/actors.go
  - pkg/auditlog/events.go
  - internal/services/auditlogs/insert.go
  - svc/api/routes/v2_keys_verify_key/handler.go
  - web/apps/dashboard/lib/limits.ts
  - pkg/clickhouse/schema/025_audit_logs_raw_v1.sql
  - web/apps/dashboard/app/(app)/[workspaceSlug]/audit/components/controls/components/logs-filters/index.tsx
---

The audit log records every change made through the API or the dashboard: who did it, what they did, what it touched, and from where. Use it to answer "who deleted that key?" or to show a compliance reviewer. Entries are written automatically, and nobody can edit or delete them.

## What an entry contains

Each entry has:

- **An event**, such as `key.create` or `portal.session.exchange`. See [Audit log event types](/platform/workspace/audit-logs/event-types).
- **An actor**, with a type, ID, and name. The type is `user` (someone in the dashboard), `rootkey` (an API call), `portalEndUser` (one of your customers in a [developer portal](/api-management/portal/overview)), `github` (a push or pull request on a connected repository), or `system` (Unkey itself).
- **The resources** it touched, each with a type, ID, name, and optional details such as the fields that changed.
- **The caller's IP address and user agent**, and a readable description.
- **A correlation ID** that groups entries from one action, such as a key creation and the permissions it attached.

Every entry is in the `unkey_mutations` bucket, the only option in the dashboard's **Bucket** filter.

## Which actions are logged

Every create, update, and delete is logged, for keyspaces, keys, identities, roles, permissions, rate limit namespaces and overrides, portals, every Compute resource, and workspace and team changes. Reads aren't logged, with two exceptions: `key.verify` when a **root key** calls `keys.verifyKey`, and `ratelimit.limit` when a root key checks a rate limit. Verifications by the gateway or a portal session aren't in the audit log. They're in [analytics](/api-management/analytics/overview).

## Reading the log

Open **Audit log** in the dashboard sidebar. Entries are newest first. Expand one to see its resources, description, IP address, and user agent. Filter by **Events**, **Users**, **Root Keys**, **Bucket**, and time range. Press **F** to open the filters and **Q** to reopen saved filters. Every workspace role can read the log, including viewers, but only admins see keys in the **Root Keys** filter.

<Frame>
  <img src="/images/dashboard/api-management--audit-logs-overview--audit-log.png" alt="Audit log page listing events with time, actor, action, and event, with one entry open showing its description, actor, and details" />
</Frame>

## Retention

Your plan sets how long entries are kept. Check the `Audit log retention` row on **Settings > Limits**. See [Limits](/platform/billing/limits).

| Plan | Audit log retention |
| --- | --- |
| Free, no Compute plan | 3 days |
| API Pro, any tier | 14 days |
| Compute Starter | 7 days |
| Compute Pro | 14 days |
| Compute Business | 30 days |
| Enterprise | Custom |

No plan keeps audit logs longer than 90 days. To keep them longer, or in your own systems, stream them out with a [log drain](/platform/workspace/log-drains).

---

### Web Apps Design Docs Index (`web/apps/design/docs/index.mdx`)

---
title: Overview
description: The Unkey design system, for building consistent experiences.
sidebar:
  order: 0
---

<CardGroup cols={2}>
  <Card title="Primitives" href="/primitives/skeleton" icon="box">
    One component at a time.
  </Card>
  <Card title="Patterns" href="/patterns/layout" icon="layout-template">
    Several components composed into a page.
  </Card>
</CardGroup>

---

### .Mintlify Assistant (`docs/.mintlify/Assistant.md`)

You are the Unkey documentation assistant. Help developers use Unkey accurately, using these docs as your source of truth.

## Tone
- Be friendly and guiding. Lead with the answer, then a short next step when it helps.
- Prefer plain language over jargon. When a Unkey term matters, use the glossary term and say which product it belongs to.
- Keep answers focused. Link to the relevant docs page instead of restating a whole guide.

## Product context
Unkey has two separate products plus shared platform pages. A reader can use either product without the other. Always say which product an answer belongs to when it could be ambiguous.

- **Compute**: builds, deploys, and runs apps behind a gateway (projects, apps, environments, deployments, gateway policies).
- **API Management**: issues and verifies API keys, enforces rate limits, manages identities and permissions, and reports usage (keyspaces, keys, identities, credits).
- **Platform** (shared): workspace, billing, root keys, CLI, audit logs, and error codes.

## Accuracy rules
- Only state product behavior, API shapes, limits, plans, and error codes that these docs support.
- If the docs do not clearly cover something, say so. Do not invent endpoints, fields, permissions, plan limits, or dashboard paths.
- Prefer citing or linking the page that supports your answer. When you are unsure between two meanings, ask a clarifying question or point to the glossary.
- Never invent or guess secret values, root keys, or API keys.

## Terminology
Follow the [glossary](/platform/glossary). These trips people up most:

- **Root key** vs **API key**: a root key (`unkey_…`) authenticates you to the Unkey API and CLI. An API key is a credential you issue to users of your application and verify with `keys.verifyKey`.
- **Workspace**: top-level container (`ws_` ID). Do not call it a project or organization.
- **Keyspace**: API Management container for keys (called an API in endpoint names like `apis.createApi`).
- **Project** / **app**: Compute only. A project groups apps; an app is what you deploy.
- **Environment**: in Compute, production or preview. In API Management, an optional label on a key (`live` / `test`) that does not change key behavior.
- **Rate limiting**: three different features share this name — the standalone `ratelimit.limit` API, key/identity rate limits checked on verify, and Compute gateway rate-limit policies. Say which one you mean.
- **Verify**: usually `keys.verifyKey` in API Management. Compute also has custom-domain verification and gateway key-auth.
- **Role**: dashboard roles (admin / developer) are not the same as API Management roles on keys.
- **Tier** vs **plan**: billing says "tier" for API Management and "plan" for Compute; they bill separately.

## API shape
- Unkey API calls are HTTP `POST` to `https://api.unkey.com/v2/{service}.{procedure}` with a root key in the `Authorization: Bearer` header.
- Root keys are workspace-scoped; you do not pass a workspace ID with the key.
- Error codes look like `err:{system}:{category}:{specific}` and each has a page at `/errors/{system}/{category}/{specific}`.


## Compute pricing
Explain Compute billing from these rules. Point people to [unkey.com/pricing](https://unkey.com/pricing) for current unit rates, plan fees, included credits, and the calculator at the bottom of that page. Do not invent rates that aren't on that page or in these docs.

Each Compute plan has a monthly fee and included usage credit. Usage above the credit is billed on:

- **vCPU**: average active virtual CPU per running instance. Billed on actual usage, not the configured ceiling. Idle time waiting on I/O is not active CPU.
- **Memory**: average memory used per running instance. Billed on actual usage, not the configured ceiling.
- **Ephemeral disk**: volume mounted per instance. Billed on the allocated size while the instance runs, up to 10 GiB per instance.
- **Egress**: total outbound network data transfer per month.
- **Active / verified keys**: distinct keys with at least one verification during the month through a Key Auth policy on Compute (Unkey Deploy). These bill to the Compute project, not an API Management keyspace.

Runtime settings are ceilings. Most people never pin 100% CPU or memory. If someone configures 1 vCPU but averages 0.5 vCPU, bill the 0.5. Work an example from the published per-second rates on the pricing page (for example, cost ≈ average_vCPU × seconds_running × vCPU_rate), then suggest the calculator for a full estimate. Preview deployments bill at the same rates as production. Unused included credits do not roll over.

## Escalation
- When someone needs a human (account access, billing changes, enabling a support-gated feature, workspace deletion, or anything the docs say requires support), direct them to support@unkey.com.
- You may mention Discord (https://unkey.com/discord) as a community option, but email support first for account and support-gated requests.
- For outages or degraded service, point to https://status.unkey.com.

## Scope
- Stay on Unkey product and docs questions.
- Do not give legal, security-audit, or competitive advice beyond what these docs state.
- Do not invent comparisons to other vendors.

---

### Platform Glossary (`docs/platform/glossary.mdx`)

---
title: Glossary
description: "Look up the terms Unkey uses and which product each one belongs to."
sources:
  - web/internal/db/src/schema/keys.ts
  - web/internal/db/src/schema/limits.ts
  - web/internal/db/src/schema/workspaces.ts
  - web/internal/db/src/schema/keyAuth.ts
  - web/apps/dashboard/lib/auth/roles.ts
  - pkg/hash/sha256.go
  - web/apps/dashboard/lib/stripe/deployPlan.ts
---

Look up a term here. Some terms mean different things in different products, so each entry starts with the product that owns it.

## Terms with more than one meaning

### App

**Compute.** A service you deploy, inside a project. It has a GitHub repository or an image as its source, two environments, build and runtime settings, and a history of deployments. On this site, "app" always means this. When we mean the software you run anywhere, including outside Unkey, we say "your application".

### Environment

**Compute.** Every app has two environments, production and preview, and each deployment belongs to one. Environment variables, runtime settings, and gateway policies are set per environment. **API Management** uses the same word for an optional label on a key, the key's `environment` field, so you can tag keys as `live` or `test`. The label doesn't change how the key behaves, and it has nothing to do with Compute environments.

### Rate limiting

Three different features share this name:

- **API Management**: the standalone ratelimit API. You call `ratelimit.limit` with your own identifier and namespace and get an allow or deny.
- **API Management**: key and identity rate limits. They're attached to a key or an identity and checked when the key is verified.
- **Compute**: the gateway rate limit policy. The gateway applies it to requests before they reach your app.

### Verify

**API Management.** Key verification is `keys.verifyKey`: you send a key, and we tell you whether it's valid and, if not, why. **Compute** uses the word twice more: custom domain verification, where you prove you control a domain before the gateway serves it, and the gateway key-auth policy, which verifies keys on incoming requests so your app only sees verified callers.

## Platform

### Workspace

**Platform.** The top-level container for everything else. It has a slug used in dashboard URLs, a `ws_` ID used in the API, and its own team, billing, and limits. Workspaces are fully separate from each other. See [Workspaces](/platform/workspace/overview).

### Root key

**Platform.** The credential for calling the Unkey API and using the CLI. A root key belongs to one workspace, has a list of permissions, and is stored as a SHA-256 hash like every other key. See [Root keys](/platform/root-keys/overview).

### Role

**Platform.** Admin or developer, given to each dashboard user per workspace. Developers can work with every resource in the workspace. Admins can also manage members, rename the workspace, manage root keys, and change billing. These aren't the roles you attach to API keys in API Management, which group permissions for your own users. See [Team](/platform/workspace/team).

### Tier and plan

**Platform.** The billing page says "tier" for API Management (Free by default) and "plan" for Compute (Starter, Pro, or Business). They're billed separately to the same payment method. See [Plans](/platform/billing/plans).

### Limits

**Platform.** Your workspace's ceilings, shown under **Settings > Limits**: monthly API operations, log and audit log retention, log drains, team members, and Compute resources. Most come from your API tier and Compute plan. See [Limits](/platform/billing/limits).

### Delete protection

**Platform.** A setting on keyspaces, projects, and apps that blocks deletion while it's on. A delete attempt returns `err:unkey:application:protected_resource`. See [Delete protection](/platform/security/delete-protection).

## API Management

### Keyspace

**API Management.** A container for API keys, called an API in endpoint names (`apis.createApi`). Each key belongs to one keyspace. A keyspace sets the default prefix and length for new keys, and has settings for encrypted key storage, an optional IP allow list, and delete protection.

### Key

**API Management.** A credential you give to a user of your application and verify on each request. A key has a prefix and can have a name, metadata, an expiry, credits, rate limits, permissions and roles, and an identity. We store only its hash, unless it's a recoverable key.

### Identity

**API Management.** One of your users or tenants, identified by your own `externalId`, that several keys can belong to. Rate limits on an identity are shared by all of its keys.

### Credits

**API Management.** The number of uses a key has left. Each verification uses one, and credits can refill on a schedule. At zero, verification reports the key as out of usage.

### Permission and role (keys)

**API Management.** Permissions are strings you define and attach to keys, directly or through roles. Verification can check that a key has a permission or matches a permission query. These are separate from dashboard roles.

### Recoverable key

**API Management.** A key created with `recoverable: true` in a keyspace with encrypted storage turned on. We keep an encrypted copy, so you can read the key again later. See [Key storage](/platform/security/key-storage).

## Compute

### Project

**Compute.** A group of apps, usually one per codebase or product. Compute usage is billed and reported per project, and a project can have delete protection.

### Deployment

**Compute.** One built version of an app, running in one environment. It's created from a git commit, an image, or an existing deployment. You can promote it or roll back to it, and it keeps the gateway policies that were in place when it was created.

### Gateway

**Compute.** What sits in front of every deployment and receives its traffic. It handles HTTPS, routes requests by domain, and applies the environment's policies: key authentication, rate limiting, firewall rules, OpenAPI validation, and logging.

### Gateway policy

**Compute.** A rule the gateway applies to requests for an environment. The key-auth policy uses a keyspace from API Management, which is where the two products connect.

### Replica

**Compute.** One running instance of a deployment in a region. Autoscaling can add replicas up to your plan's replicas-per-region limit. See [Limits](/platform/billing/limits).

### Spend budget

**Compute.** A monthly dollar cap on Compute usage, set by an admin. It emails you at 50, 75, and 100 percent and can optionally stop your workloads at 100 percent. See [Spend budget](/compute/configure/spend-budget).

---

### Scripts Pending Upstream (`docs/scripts/pending-upstream.txt`)

# Endpoints present in svc/api/openapi/openapi-generated.yaml but not yet published
# to the Speakeasy spec both API reference tabs render. Listing them in docs.json
# fails `mint validate`, so they stay out of the reference until upstream publishes
# them; docs-lint downgrades their coverage gap to a warning while they are here.
# `mise run docs-check` prints the current pending set under "pending upstream publish".
/v2/workspace.getLimits
/v2/workspace.getUsage
# Internal to the hosted portal and x-excluded on purpose; never listed.
/v2/portal.exchangeCode
/v2/portal.getVerifications
/v2/portal.listKeys
/v2/portal.rerollKey
# GET /v2/liveness is published without x-codeSamples and `mint validate` fails to generate its page.
/v2/liveness

---

### Snippets Cli Install (`docs/snippets/cli-install.mdx`)

import { versions } from "/snippets/versions.mdx";

```bash
npm install unkey
```

Without Node, download the archive for your platform from the [releases page](https://github.com/unkeyed/unkey/releases) and put the `unkey` binary on your `PATH` instead. Both paths ship the same binary, currently {versions.cli}.

---

### Snippets Root Key Prereq (`docs/snippets/root-key-prereq.mdx`)

<Note>
  You need a root key with the permissions listed on this page. Create one in the dashboard under **Settings > Root Keys**. See [Permission reference](/platform/root-keys/permissions-legacy) for every permission.
</Note>

---

### Snippets Versions (`docs/snippets/versions.mdx`)

export const versions = {
  cli: "2.0.150",
  tsApi: "2.5.1",
  tsRatelimit: "2.1.4",
  tsHono: "2.0.0",
  tsNextjs: "2.0.0",
  tsCache: "1.5.0",
  tsNuxt: "1.1.15",
  goSdk: "v3.0.1",
  pySdk: "3.0.3",
};

---

### Api Management Analytics Examples (`docs/api-management/analytics/examples.mdx`)

---
title: Analytics query examples
sidebarTitle: "Query examples"
description: "Copy-ready SQL for the questions people ask most about verifications and rate limits."
sources:
  - pkg/clickhouse/query-parser/validation.go
  - pkg/clickhouse/schema/001_key_verifications_raw_v2.sql
  - pkg/clickhouse/schema/003_key_verifications_per_hour_v3.sql
  - pkg/clickhouse/schema/004_key_verifications_per_day_v3.sql
  - pkg/clickhouse/schema/006_ratelimits_raw_v2.sql
  - pkg/clickhouse/schema/008_ratelimits_per_hour_v2.sql
---

Copy these queries to answer common questions. Send each as the `query` string of `analytics.getVerifications` or `analytics.getRatelimits`. You don't need a workspace filter.

Some of these reach back seven days or a calendar month, which is longer than some plans keep data. Check your [retention](/api-management/analytics/restrictions-and-quotas) first. If a query fails with `err:user:bad_request:query_range_exceeds_retention`, shorten the window.

## Verifications

### Total verifications in the last 24 hours

The per-hour rollup already sums verifications, so read `sum(count)` rather than `count()`.

```sql
SELECT sum(count) AS verifications
FROM key_verifications_per_hour_v1
WHERE time >= now() - INTERVAL 24 HOUR
```

### Outcomes per hour for the last day

```sql
SELECT
  time,
  outcome,
  sum(count) AS verifications
FROM key_verifications_per_hour_v1
WHERE time >= now() - INTERVAL 24 HOUR
GROUP BY time, outcome
ORDER BY time, outcome
```

### Valid versus rejected, per day

`sumIf` lets you split one scan into several totals. Per-day `time` is a `Date`, so compare with `today()`.

```sql
SELECT
  time AS day,
  sumIf(count, outcome = 'VALID') AS valid,
  sumIf(count, outcome != 'VALID') AS rejected,
  round(100 * valid / (valid + rejected), 2) AS valid_percent
FROM key_verifications_per_day_v1
WHERE time >= today() - INTERVAL 7 DAY
GROUP BY day
ORDER BY day
```

### Usage per customer this month

`external_id` is your ID for the identity behind each key, so it's a good way to bill.

```sql
SELECT
  external_id,
  sum(count) AS verifications,
  sum(spent_credits) AS credits
FROM key_verifications_per_day_v1
WHERE time >= toStartOfMonth(today())
  AND external_id != ''
GROUP BY external_id
ORDER BY verifications DESC
LIMIT 100
```

### Keys hitting rate limits or running out of credits

```sql
SELECT
  key_id,
  external_id,
  sumIf(count, outcome = 'RATE_LIMITED') AS rate_limited,
  sumIf(count, outcome = 'USAGE_EXCEEDED') AS usage_exceeded
FROM key_verifications_per_hour_v1
WHERE time >= now() - INTERVAL 1 DAY
GROUP BY key_id, external_id
HAVING rate_limited > 0 OR usage_exceeded > 0
ORDER BY rate_limited + usage_exceeded DESC
LIMIT 50
```

### Latency percentiles from the raw table

Latency stats have to come from the raw table. Raw `time` is in milliseconds, so convert the bound.

```sql
SELECT
  round(avg(latency), 2) AS avg_ms,
  round(quantile(0.5)(latency), 2) AS p50_ms,
  round(quantile(0.99)(latency), 2) AS p99_ms
FROM key_verifications_v1
WHERE time >= toUnixTimestamp64Milli(now() - INTERVAL 1 HOUR)
```

### Verifications per tag

Tags are an array on each row. `arrayJoin` gives one row per tag, and `has` keeps rows with a specific tag.

```sql
SELECT
  arrayJoin(tags) AS tag,
  sum(count) AS verifications
FROM key_verifications_per_day_v1
WHERE time >= today() - INTERVAL 7 DAY
GROUP BY tag
ORDER BY verifications DESC
```

```sql
SELECT sum(count) AS checkout_calls
FROM key_verifications_per_day_v1
WHERE time >= today() - INTERVAL 7 DAY
  AND has(tags, 'endpoint:checkout')
```

### Hourly buckets from the raw table

When you need a dimension the rollups don't carry, such as `region`, bucket the raw rows yourself.

```sql
SELECT
  toStartOfHour(fromUnixTimestamp64Milli(time)) AS hour,
  region,
  count() AS verifications
FROM key_verifications_v1
WHERE time >= toUnixTimestamp64Milli(now() - INTERVAL 6 HOUR)
GROUP BY hour, region
ORDER BY hour, region
```

### Gateway-verified versus API-verified

Keys verified by a Compute gateway key-auth policy carry `source = 'gateway'` and the `app_id` of the app that fronted them. Direct `keys.verifyKey` calls carry `source = 'api'` and an empty `app_id`.

```sql
SELECT
  source,
  app_id,
  sum(count) AS verifications
FROM key_verifications_per_day_v1
WHERE time >= today() - INTERVAL 7 DAY
GROUP BY source, app_id
ORDER BY verifications DESC
```

## Rate limits

### Pass rate per namespace over the last day

```sql
SELECT
  namespace_id,
  sum(passed) AS passed,
  sum(total) AS total,
  round(100 * passed / total, 2) AS pass_percent
FROM ratelimits_per_hour_v1
WHERE time >= now() - INTERVAL 24 HOUR
GROUP BY namespace_id
ORDER BY total DESC
```

### Most rejected identifiers

```sql
SELECT
  identifier,
  sum(total) - sum(passed) AS rejected
FROM ratelimits_per_hour_v1
WHERE time >= now() - INTERVAL 24 HOUR
GROUP BY identifier
HAVING rejected > 0
ORDER BY rejected DESC
LIMIT 25
```

### Checks that used an override

```sql
SELECT
  override_id,
  identifier,
  countIf(passed) AS passed,
  countIf(NOT passed) AS rejected
FROM ratelimits_v1
WHERE time >= toUnixTimestamp64Milli(now() - INTERVAL 6 HOUR)
  AND override_id != ''
GROUP BY override_id, identifier
ORDER BY rejected DESC
```

## Combining tables

Use a CTE to answer a two-step question.

```sql
WITH top_customers AS (
  SELECT external_id
  FROM key_verifications_per_day_v1
  WHERE time >= today() - INTERVAL 7 DAY
    AND external_id != ''
  GROUP BY external_id
  ORDER BY sum(count) DESC
  LIMIT 10
)
SELECT
  time,
  external_id,
  sum(count) AS verifications
FROM key_verifications_per_hour_v1
WHERE time >= now() - INTERVAL 24 HOUR
  AND external_id IN (SELECT external_id FROM top_customers)
GROUP BY time, external_id
ORDER BY time, external_id
```

If a query is rejected, the [troubleshooting page](/api-management/analytics/troubleshooting) maps each error to its fix.

---

### Api Management Analytics Query Language (`docs/api-management/analytics/query-language.mdx`)

---
title: Analytics query language
sidebarTitle: "Query language"
description: "Write SQL against your verification and rate limit data."
sources:
  - pkg/clickhouse/query-parser/parser.go
  - pkg/clickhouse/query-parser/validation.go
  - pkg/clickhouse/query-parser/tables.go
  - pkg/clickhouse/query-parser/filter.go
  - pkg/clickhouse/query-parser/limits.go
  - pkg/clickhouse/query-parser/time_range.go
  - pkg/clickhouse/schema/001_key_verifications_raw_v2.sql
  - pkg/clickhouse/schema/002_key_verifications_per_minute_v3.sql
---

Analytics queries are ClickHouse SQL. You can filter, group, aggregate, and join your own data. Anything not listed here is rejected with a 400 that names the problem.

## What a query must be

A query is one `SELECT` statement with a `FROM` clause. An empty body or a second statement fails with `err:user:bad_request:invalid_analytics_query`. Anything that isn't a `SELECT` (`INSERT`, `SHOW`, `DESCRIBE`, and so on) fails with `err:user:bad_request:invalid_analytics_query_type`.

You can use `WHERE`, `GROUP BY`, `HAVING`, `ORDER BY`, `LIMIT` and `OFFSET`, `WITH` (CTEs), subqueries in `FROM` and `IN (...)`, `JOIN`, `UNION`, and `EXCEPT`. The same rules apply to every nested `SELECT`.

These aren't allowed:

- A `SETTINGS` clause.
- Table functions such as `numbers()`, `url()`, or `remote()`.
- `IN` followed by a table name. Write `IN (SELECT ... FROM ...)` instead.

## Tables

Only the tables listed on the [overview](/api-management/analytics/overview), and CTEs you define, can appear in `FROM` and `JOIN`. Any other table fails with `err:user:bad_request:invalid_analytics_table`. Aliases (`FROM key_verifications_v1 AS v`) work as usual.

## Filters and limits added for you

You don't need a workspace filter. Every query only sees your workspace's rows, and only the keyspaces or namespaces your root key can read. Your own `WHERE` narrows it further. A query for a keyspace you can't see returns no rows, not an error.

Every `SELECT` is capped at your workspace's result row limit, 10,000,000 by default. A smaller `LIMIT` you write is kept.

## Functions

Only the functions in this table are allowed. Names can be any case. Any other function fails with `err:user:bad_request:invalid_analytics_function`. Commonly missed ones include `toUnixTimestamp`, `avgMerge`, `quantilesTDigestMerge`, `multiIf`, `argMax`, `topK`, `median`, `dateDiff`, `row_number`, `rank`, `lagInFrame`, and every `JSONExtract*` except `JSONExtractString`. An allowed aggregate still works with `OVER`, as in `sum(spent_credits) OVER (PARTITION BY key_id)`. Operators such as `+`, `=`, `<`, `AND`, `OR`, `NOT`, `LIKE`, `IN`, and `BETWEEN` always work.

| Family | Allowed functions |
| --- | --- |
| Aggregates | `count`, `sum`, `avg`, `min`, `max`, `any`, `groupArray`, `groupUniqArray`, `uniq`, `uniqExact`, `quantile` |
| Conditional aggregates and expressions | `countIf`, `sumIf`, `if`, `case`, `coalesce` |
| Date and time | `now`, `now64`, `today`, `toDate`, `toDateTime`, `toDateTime64`, `toStartOfMinute`, `toStartOfHour`, `toStartOfDay`, `toStartOfWeek`, `toStartOfMonth`, `toStartOfQuarter`, `toStartOfYear`, `date_trunc`, `formatDateTime`, `fromUnixTimestamp64Milli`, `toUnixTimestamp64Milli` |
| Intervals | `toIntervalNanosecond`, `toIntervalMicrosecond`, `toIntervalMillisecond`, `toIntervalSecond`, `toIntervalMinute`, `toIntervalHour`, `toIntervalDay`, `toIntervalWeek`, `toIntervalMonth`, `toIntervalQuarter`, `toIntervalYear`. ClickHouse's `INTERVAL N UNIT` literal, as in `now() - INTERVAL 7 DAY`, is syntax rather than a function and is also accepted. |
| Strings | `lower`, `upper`, `substring`, `concat`, `length`, `trim`, `startsWith`, `endsWith` |
| JSON | `JSONExtractString` |
| Math | `round`, `floor`, `ceil`, `abs` |
| Type conversion | `toString`, `toInt32`, `toInt64`, `toFloat64` |
| Arrays | `has`, `hasAny`, `hasAll`, `arrayJoin`, `arrayFilter` |

If you need a function that isn't listed, tell us which one.

## Working with time

In the raw tables, `time` is milliseconds since the Unix epoch (`Int64`), so convert when you compare with "now": `time > toUnixTimestamp64Milli(now() - INTERVAL 1 HOUR)`. To group raw rows by hour, convert the other way: `toStartOfHour(fromUnixTimestamp64Milli(time))`. In the rollups, `time` is a `DateTime` (per-minute, per-hour) or a `Date` (per-day, per-month), so compare directly: `time > now() - INTERVAL 7 DAY`.

Queries only return data inside your plan's retention window. What happens when you ask for older data depends on how you write the time filter:

- **`INTERVAL N UNIT`, a literal date, or `today()`**: a start time older than your retention fails with `err:user:bad_request:query_range_exceeds_retention`.
- **`toIntervalDay(N)` or no start time at all**: the query runs and quietly returns only the rows inside retention.

For example, `time >= now() - INTERVAL 400 DAY` fails, while `time >= now() - toIntervalDay(400)` returns fewer rows with no error. Use `INTERVAL N UNIT` if you'd rather get an error than a short answer. Retention per plan is on [Analytics restrictions and quotas](/api-management/analytics/restrictions-and-quotas).

## Aggregate state columns

In the rollup tables, read `count`, `spent_credits`, `total`, and similar columns with `sum()`, never `count()`, because each row already sums many verifications. You can't read the rollups' `latency_avg`, `latency_p75`, and `latency_p99` columns, because the `-Merge` functions they need aren't allowed. Use the raw table instead: `avg(latency)` and `quantile(0.99)(latency)`. The [table pages](/api-management/analytics/tables/key-verifications) mark which columns are which.

---

### Api Management Analytics Restrictions And Quotas (`docs/api-management/analytics/restrictions-and-quotas.mdx`)

---
title: Analytics restrictions and quotas
sidebarTitle: "Restrictions and quotas"
description: "The limits every analytics query runs under and the error each one returns."
sources:
  - pkg/clickhouse/query-parser/parser.go
  - pkg/clickhouse/query-parser/validation.go
  - pkg/clickhouse/query-parser/time_range.go
  - pkg/clickhouse/user.go
  - pkg/clickhouse/errors.go
  - internal/services/analytics/executor.go
  - internal/services/analytics/service.go
  - svc/api/routes/v2_analytics_get_verifications/handler.go
  - web/internal/db/src/schema/clickhouse_workspace_settings.ts
  - web/apps/dashboard/lib/limits.ts
  - pkg/clickhouse/schema/001_key_verifications_raw_v2.sql
  - pkg/clickhouse/schema/006_ratelimits_raw_v2.sql
  - svc/api/internal/middleware/errors.go
---

Every analytics query runs under these limits. Most queries never hit them. When you do, find the error code here to see which limit you hit. For what SQL is allowed, see [Analytics query language](/api-management/analytics/query-language).

## Query shape limits

These are checked before the query runs. Going over one returns HTTP 400, except nesting depth.

| Limit | Value | Error code |
| --- | --- | --- |
| Query text | 16 KiB | `err:user:bad_request:invalid_analytics_query` |
| Projected columns across all `SELECT`s | 64 | `err:user:bad_request:invalid_analytics_query` ("Analytics query projects too many columns") |
| Query complexity (parsed expression nodes) | 2,000 | `err:user:bad_request:invalid_analytics_query` ("Analytics query is too complex") |
| Nesting depth | 100 | The query fails when it runs, not before |
| Statements | exactly one `SELECT` with `FROM` | `invalid_analytics_query` or `invalid_analytics_query_type` |
| Tables | public aliases only | `err:user:bad_request:invalid_analytics_table` |
| Functions | the allow-list | `err:user:bad_request:invalid_analytics_function` |

## Execution limits

These apply while the query runs. Hitting one returns HTTP 422.

| Limit | Value | Error code |
| --- | --- | --- |
| Execution time | 10 seconds | `err:user:unprocessable_entity:query_execution_timeout` |
| Memory per query | 1 GB | `err:user:unprocessable_entity:query_memory_limit_exceeded` |
| Result rows | 10,000,000. Also applied as a `LIMIT` on every `SELECT`. | `err:user:unprocessable_entity:query_rows_limit_exceeded` |
| Encoded response size | 4 MiB | `err:user:unprocessable_entity:query_memory_limit_exceeded` ("Query result exceeds the maximum response size.") |

The 4 MiB response size is the limit people hit first. To stay under it, page with `LIMIT` and `OFFSET`, aggregate instead of selecting raw rows, or narrow the time window. `NaN` and infinity come back as `null`.

## Quota per workspace

By default, each workspace can run 1,000 queries and 1,800 seconds of total query time per rolling hour. Going over either returns HTTP 429 with `err:user:too_many_requests:query_quota_exceeded`. Wait for the window to roll over, or contact [support@unkey.com](mailto:support@unkey.com) if you need more.

## Retention

Queries only return rows inside your plan's log retention. If your time filter starts earlier than that, the query fails with HTTP 400 and `err:user:bad_request:query_range_exceeds_retention`, and the detail names the earliest date you can query. A filter written with `toIntervalDay(N)` instead of `INTERVAL N DAY` doesn't fail. It just leaves the older rows out. See [Working with time](/api-management/analytics/query-language#working-with-time).

| Plan | Log retention |
| --- | --- |
| Free, no Compute plan | 1 day |
| API Pro, any tier | 7 days |
| Compute Starter | 3 days |
| Compute Pro | 7 days |
| Compute Business | 14 days |

Plans agreed with sales have their own retention. Your actual value is the `Log retention` row on **Settings > Limits**. See [Limits](/platform/billing/limits).

Each table also has a maximum age, whatever your plan:

- **Verifications:** raw, per-minute, and per-hour 90 days; per-day 365 days; per-month 3 years.
- **Rate limits:** raw one month; per-minute 7 days; per-hour 30 days; per-day 100 days; per-month 3 years.

## Other errors

| Status | Code | Meaning |
| --- | --- | --- |
| 412 | `err:unkey:data:analytics_not_configured` | Analytics is not enabled for this workspace yet. See [Analytics](/api-management/analytics/overview). |
| 403 | `err:unkey:authorization:insufficient_permissions` | The root key lacks `read_analytics` (or `read_gateway_requests` / `read_runtime_logs`) for the endpoint. |
| 503 | `err:unkey:data:analytics_connection_failed` | Analytics is temporarily unavailable. Retry with backoff. |

All errors use the standard [API error format](/platform/api/errors), and each error's `type` field links to its page.

---

### Api Management Analytics Troubleshooting (`docs/api-management/analytics/troubleshooting.mdx`)

---
title: Analytics troubleshooting
sidebarTitle: "Troubleshooting"
description: "Fix empty results, rejected queries, and timeouts."
sources:
  - pkg/clickhouse/query-parser/parser.go
  - pkg/clickhouse/query-parser/validation.go
  - pkg/clickhouse/query-parser/tables.go
  - pkg/clickhouse/query-parser/time_range.go
  - pkg/clickhouse/errors.go
  - pkg/clickhouse/user.go
  - internal/services/analytics/service.go
  - svc/api/routes/v2_analytics_get_verifications/handler.go
---

Start with the HTTP status:

- **200 with no rows:** the query worked but matched nothing.
- **400:** the query was rejected.
- **422 or 429:** the query ran and hit a limit.
- **412:** analytics isn't turned on.
- **403:** the root key is missing a permission.

## No rows came back

**Check the time filter.** Raw tables store `time` in milliseconds, so `WHERE time >= now() - INTERVAL 1 DAY` matches nothing. Write `time >= toUnixTimestamp64Milli(now() - INTERVAL 1 DAY)` instead. Rollup tables take `DateTime` or `Date` values directly.

**Check IDs.** `key_space_id` is the keyspace's `ks_` ID, not the `api_` ID. `namespace_id` is an `rlns_` ID, not the namespace name. Values are case-sensitive, and `outcome` values are upper case (`VALID`, not `valid`).

**Check retention.** Data older than your plan's log retention is left out, often without an error. See [Working with time](/api-management/analytics/query-language#working-with-time).

**Check the root key.** If it has `read_analytics` for specific keyspaces or namespaces only, rows outside them are left out without an error.

## The query was rejected (400)

| Detail | Cause | Fix |
| --- | --- | --- |
| Invalid SQL syntax | The query couldn't be parsed. | Check quoting, commas, and parentheses. Remember that string literals use single quotes. |
| Only SELECT queries are allowed (`invalid_analytics_query_type`) | The statement is not a `SELECT`. | Analytics is read-only; there is no `INSERT`, `SHOW`, or `DESCRIBE`. Columns are listed on the table pages. |
| Analytics queries must contain exactly one statement | Two statements, or a trailing statement after a semicolon. | Send one `SELECT` per request. |
| Query must have a FROM clause | `SELECT 1` or a bare expression. | Every query reads from a public table. |
| Access to table 'x' is not allowed (`invalid_analytics_table`) | A table that isn't one of the public names, a `system` or `information_schema` table, or a typo in a public name. | Use the public names on the [overview](/api-management/analytics/overview), such as `key_verifications_v1`. |
| Function 'x' is not allowed (`invalid_analytics_function`) | A function that isn't allowed, including `row_number`, `rank`, and every `-Merge` function. | Rewrite with an allowed function; see [Analytics query language](/api-management/analytics/query-language). For latency, aggregate the raw table. |
| SETTINGS clauses are not allowed | A `SETTINGS` clause. | Remove it. Execution limits are fixed per workspace. |
| Table-backed IN expressions are not supported | `x IN some_table`. | Write `x IN (SELECT x FROM some_table)`. |
| Analytics query projects too many columns | More than 64 selected columns across all `SELECT`s. | Select fewer columns or split the query. |
| Analytics query is too complex | More than 2,000 parsed expression nodes. | Simplify; long `IN` lists are the usual culprit. |
| Analytics query exceeds the maximum length | The query text is over 16 KiB. | Shorten it, usually by replacing a long `IN` list with a subquery. |
| The query reads a column that is not available | `SELECT *` on `gateway_requests_v1` or `runtime_logs_v1`. Only their documented columns can be read. | Name the columns you want instead of `*`. They are listed on [Gateway request and runtime log analytics](/compute/observe/analytics-api). |
| Cannot query data older than N days (`query_range_exceeds_retention`) | A lower bound on `time` is before your retention window. | Move the bound forward. The detail names the earliest allowed date. Retention per plan is on [Analytics restrictions and quotas](/api-management/analytics/restrictions-and-quotas). |

## The query ran but failed (422 or 429)

| Code | Fix |
| --- | --- |
| `query_execution_timeout` | The query exceeded 10 seconds. Switch from the raw table to a rollup, add or tighten the `time` bound, and aggregate rather than selecting raw rows. |
| `query_memory_limit_exceeded` with "exceeds the maximum response size" | The encoded result is over 4 MiB. Add `LIMIT`, aggregate, or page with `LIMIT` and `OFFSET`. |
| `query_memory_limit_exceeded` otherwise | The query needed more than 1 GB. `groupArray` over many rows and high-cardinality `GROUP BY` on the raw table are the common causes; aggregate on a rollup. |
| `query_rows_limit_exceeded` | More than 10,000,000 result rows. Aggregate or narrow. |
| `query_quota_exceeded` (429) | More than 1,000 queries or 1,800 seconds of execution in the current hour. Cache results on your side and retry after the window rolls over. |

## Analytics isn't configured (412)

`err:unkey:data:analytics_not_configured` means analytics isn't turned on for your workspace. Email [support@unkey.com](mailto:support@unkey.com) with your workspace ID to turn it on. A 503 with `analytics_connection_failed` is different: analytics was briefly unavailable, so retry with backoff.

## Permission denied (403)

`insufficient_permissions` names the permission the root key needs: `api.*.read_analytics` or `api.<api_id>.read_analytics` for verifications, `ratelimit.*.read_analytics` or `ratelimit.<namespace_id>.read_analytics` for rate limits. Grant it under **Settings > Root Keys**. See [Root key permissions](/platform/root-keys/permissions).

## Numbers that look wrong

- **A rollup total is far too low.** You probably wrote `count()` instead of `sum(count)`. Each rollup row sums many verifications.
- **A percentage is `null`.** It divided by zero.
- **`external_id` is empty for many rows.** Those keys have no identity. Group by `key_id` instead, or attach identities when you create keys.

---

### Api Management Authorization Managing Key Permissions (`docs/api-management/authorization/managing-key-permissions.mdx`)

---
title: Managing key roles and permissions
sidebarTitle: "Managing key permissions"
description: "Change the roles and permissions a key holds after it exists."
sources:
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/setPermissions/V2KeysSetPermissionsRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/addPermissions/V2KeysAddPermissionsRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/addRoles/V2KeysAddRolesRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/removeRoles/V2KeysRemoveRolesRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/setRoles/V2KeysSetRolesRequestBody.yaml
  - svc/api/routes/v2_keys_create_key/handler.go
  - svc/api/routes/v2_keys_update_key/handler.go
  - svc/api/routes/v2_keys_add_permissions/handler.go
  - svc/api/routes/v2_keys_remove_permissions/handler.go
  - svc/api/routes/v2_keys_set_permissions/handler.go
  - svc/api/routes/v2_keys_add_roles/handler.go
  - svc/api/routes/v2_keys_remove_roles/handler.go
  - svc/api/routes/v2_keys_set_roles/handler.go
  - web/apps/dashboard/components/api-keys-table/components/actions/keys-table-action.popover.constants.tsx
---

import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

Change the roles and permissions on a key at any time. Set them when you create the key, replace them with `keys.updateKey`, or add and remove them with six dedicated endpoints. Changes take about 10 seconds to reach verification. [Verifying keys](/api-management/keys/verifying-keys#how-quickly-changes-take-effect) describes that timing.

<RootKeyPrereq />

All of these need `api.*.update_key` or `api.<api_id>.update_key` (creation needs `create_key`). Four of the incremental endpoints also need the matching `rbac.*` action: `add_permission_to_key` for `keys.addPermissions`, `remove_permission_from_key` for `keys.removePermissions`, both for `keys.setPermissions`, and `add_role_to_key` for `keys.addRoles`. `keys.removeRoles` and `keys.setRoles` need nothing beyond `update_key`.

`keys.addPermissions` and `keys.setPermissions` only create a new permission slug if the root key also has `rbac.*.create_permission`. Without it, an unknown slug fails with HTTP 403 `err:unkey:authorization:insufficient_permissions`. (`keys.createKey` and `keys.updateKey` create new slugs without that permission.) Roles are never created for you. An unknown role fails with HTTP 404 `err:unkey:data:role_not_found`.

## At creation

`keys.createKey` accepts `roles` (up to 100 names, each 1 to 128 characters) and `permissions` (up to 1000 slugs, each 1 to 128 characters matching `^[a-zA-Z0-9_:\-\.\*]+$`).

```bash
curl -X POST https://api.unkey.com/v2/keys.createKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "apiId": "api_...",
    "roles": ["editor"],
    "permissions": ["reports.export"]
  }'
```

## Replace everything

`keys.updateKey` takes the same two fields. Sending a list replaces it in full. Leaving a field out keeps it as it is.

## Adjust incrementally

Each endpoint takes `keyId` and an array of permission slugs or role names (not IDs). The add and remove endpoints need at least one entry, so `[]` fails with HTTP 400. Send `[]` to `keys.setPermissions` or `keys.setRoles` to remove everything.

| Endpoint | Effect |
| --- | --- |
| `keys.addPermissions` | Attaches the listed permissions; ones already attached are left alone. |
| `keys.removePermissions` | Detaches the listed permissions; ones not attached are ignored. |
| `keys.setPermissions` | Replaces the direct permissions with the list; `[]` removes all. |
| `keys.addRoles` | Assigns the listed roles. |
| `keys.removeRoles` | Unassigns the listed roles. |
| `keys.setRoles` | Replaces the roles with the list; `[]` removes all. |

```bash
curl -X POST https://api.unkey.com/v2/keys.addPermissions \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "keyId": "key_...", "permissions": ["documents.delete"] }'
```

Every change writes audit events, such as `authorization.connect_permission_and_key` and `authorization.disconnect_role_and_key`, so you can see the history of a key's access.

## Direct permissions versus roles

Removing a permission from a key doesn't help if one of its roles also grants it. To take a permission away completely, remove it from the key and from the key's roles, or remove the role. To change what a role contains, call `permissions.setRolePermissions`. That changes every key with the role. See [Roles and permissions](/api-management/authorization/roles-and-permissions).

## From the dashboard

Choose **Manage roles and permissions** from a key's actions menu, pick roles and permissions, and save. The **Create key** dialog has the same pickers in its permissions step.

---

### Api Management Authorization Permission Queries (`docs/api-management/authorization/permission-queries.mdx`)

---
title: Permission queries
description: "Ask for the permissions a request needs when you verify a key."
sources:
  - pkg/rbac/parser.go
  - pkg/rbac/lexer.go
  - pkg/rbac/rbac.go
  - pkg/rbac/doc.go
  - svc/api/routes/v2_keys_verify_key/handler.go
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/index.yaml
  - internal/services/keys/validation.go
  - pkg/codes/user_request.go
  - svc/api/routes/v2_keys_create_key/handler.go
---

Send a permission query in the `permissions` field of `keys.verifyKey` to check that a key has the permissions a request needs. A query combines permission slugs with `AND`, `OR`, and parentheses. If the key's permissions (its own plus its roles') don't satisfy it, verification fails with `code: INSUFFICIENT_PERMISSIONS` and no rate limit or credits are used.

## Grammar

```text
expression    -> andExpression (OR andExpression)*
andExpression -> primary (AND primary)*
primary       -> PERMISSION | "(" expression ")"
```

A `PERMISSION` is a slug made of letters, digits, `.`, `_`, `-`, `:`, `*`, and `/`. `AND` and `OR` can be any case. `AND` is evaluated before `OR`, as in SQL, and parentheses change that. The whole query can be up to 1000 characters.

| Query | Passes when the key has |
| --- | --- |
| `documents.read` | `documents.read` |
| `documents.read AND documents.write` | both |
| `admin OR editor` | either |
| `admin OR documents.read AND documents.write` | `admin`, or both document permissions (`AND` groups first) |
| `(admin OR editor) AND billing:view` | `billing:view` plus one of the two |

```bash
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "key": "sk_live_...", "permissions": "(admin OR editor) AND billing:view" }'
```

## Matching is exact

Each slug in the query must match one of the key's permissions exactly, including case. There are no wildcards: a key with `documents.*` does **not** pass a query for `documents.read`. A query for `documents.*` only passes for a key that has a permission literally named `documents.*`.

<Warning>
Case matters when you query but not when you attach. If a permission is stored as `Documents.Read`, attaching `documents.read` finds it and attaches it, but a query for `documents.read` fails. Pick one casing and use it everywhere.
</Warning>

To grant a family of permissions, attach them to a role and assign the role. See [Roles and permissions](/api-management/authorization/roles-and-permissions).

## Outcomes and errors

| Situation | Result |
| --- | --- |
| Expression is true for the key | Verification continues; `data.permissions` and `data.roles` list what the key holds. |
| Expression is false | HTTP 200, `valid: false`, `code: INSUFFICIENT_PERMISSIONS`. |
| Expression does not parse: unbalanced parentheses, empty parentheses, a dangling operator, or an illegal character | HTTP 400 [`err:user:bad_request:permissions_query_syntax_error`](/errors/user/bad_request/permissions_query_syntax_error) with the position of the problem in `detail`. |
| Query is empty or longer than 1000 characters | HTTP 400. |
| No `permissions` field | No permission check; the response still lists the key's permissions and roles. |

## Checking in your own code

When the decision needs data Unkey doesn't have, verify without a query and read `data.permissions`:

```typescript authorize.ts
const { data } = await unkey.keys.verifyKey({ key: incomingKey });
if (!data.valid) {
  return deny(data.code);
}

const permissions = data.permissions ?? [];
const document = await db.documents.find(documentId);

const canDelete =
  permissions.includes("admin") ||
  (permissions.includes("documents.delete") && document.ownerId === data.identity?.externalId);
```

You can combine both: send a query for the part Unkey can decide, and use the returned list for the rest.

---

### Api Management Authorization Roles And Permissions (`docs/api-management/authorization/roles-and-permissions.mdx`)

---
title: Roles and permissions
description: "Define the permissions your API checks and group them into roles."
sources:
  - svc/api/openapi/spec/paths/v2/permissions/createPermission/V2PermissionsCreatePermissionRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/permissions/createRole/V2PermissionsCreateRoleRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/permissions/setRolePermissions/V2PermissionsSetRolePermissionsRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/permissions/listPermissions/V2PermissionsListPermissionsRequestBody.yaml
  - svc/api/openapi/spec/common/Permission.yaml
  - svc/api/openapi/spec/common/Role.yaml
  - svc/api/routes/v2_permissions_create_permission/handler.go
  - svc/api/routes/v2_permissions_create_role/handler.go
  - svc/api/routes/v2_permissions_set_role_permissions/handler.go
  - svc/api/routes/v2_permissions_delete_role/handler.go
  - svc/api/routes/v2_permissions_delete_permission/handler.go
  - svc/api/routes/v2_permissions_list_permissions/handler.go
  - svc/api/routes/v2_permissions_list_roles/handler.go
  - internal/services/caches/caches.go
  - pkg/cache/cache.go
  - web/apps/dashboard/app/(app)/[workspaceSlug]/authorization/permissions/components/upsert-permission/upsert-permission.schema.ts
---

import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

Create a permission for each action your API checks, and group them into roles for the access levels you offer. Permissions and roles belong to the workspace, so one set covers every keyspace. To attach them to keys, see [Managing key roles and permissions](/api-management/authorization/managing-key-permissions).

<RootKeyPrereq />

The endpoints need the matching `rbac.*` permission: `create_permission`, `read_permission`, `delete_permission`, `create_role`, `read_role`, `delete_role`, and `add_permission_to_role` together with `remove_permission_from_role` for `permissions.setRolePermissions`.

## Permissions

<ParamField body="name" type="string" required>
  1 to 512 characters. A readable label shown in the dashboard.
</ParamField>

<ParamField body="slug" type="string" required>
  1 to 128 characters matching `^[a-zA-Z0-9_:\-\.\*]+$`, unique within the workspace. This is the string keys hold and queries check. `documents.read` and `billing:write` are both fine. An asterisk is allowed but is a literal character, not a wildcard. See [Permission queries](/api-management/authorization/permission-queries).
</ParamField>

<ParamField body="description" type="string">
  Up to 512 characters of internal documentation.
</ParamField>

```bash
curl -X POST https://api.unkey.com/v2/permissions.createPermission \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Read documents", "slug": "documents.read", "description": "List and fetch documents" }'
```

The response has `permissionId` (`perm_...`). A duplicate slug fails with HTTP 409 [`err:unkey:data:permission_already_exists`](/errors/unkey/data/permission_already_exists).

- `permissions.getPermission` takes the ID or slug.
- `permissions.listPermissions` takes `limit` (1 to 100, default 100), `cursor` (up to 1024 characters), and `search` (up to 256 characters, matched against ID, name, slug, or description, ignoring case).
- `permissions.updatePermission` takes the ID or slug in `permission`, plus any of `name`, `slug`, and `description`. Omitted fields stay unchanged, and `description: null` removes the description. It needs `unkey:v1:<workspace_id>:projects/<project_id>/rbac/permissions/<permission_id>#write`. A slug that another permission already uses fails with HTTP 409. Keys and roles keep the permission after a slug change. Key verification can return the old slug for a short time while caches refresh.
- `permissions.deletePermission` deletes the permission and removes it from every role and key.

## Roles

<ParamField body="name" type="string" required>
  1 to 128 characters, unique within the workspace. Keys refer to roles by name. Spaces are allowed.
</ParamField>

<ParamField body="description" type="string">
  Up to 512 characters.
</ParamField>

<ParamField body="permissions" type="string[]">
  Permission slugs to attach. Slugs that don't exist yet are created if the root key also has `rbac.*.create_permission`. Without it, an unknown slug fails with HTTP 403 `err:unkey:authorization:insufficient_permissions`. Leave it out or send `[]` for an empty role.
</ParamField>

```bash
curl -X POST https://api.unkey.com/v2/permissions.createRole \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "editor",
    "description": "Read and write documents",
    "permissions": ["documents.read", "documents.write"]
  }'
```

The response has `roleId` (`role_...`). A duplicate name fails with HTTP 409 [`err:unkey:data:role_already_exists`](/errors/unkey/data/role_already_exists). `permissions.getRole` and `permissions.listRoles` return roles with their permissions. `permissions.deleteRole` deletes the role, and keys lose its permissions unless they get them another way.

`permissions.updateRole` takes the ID or name in `role`, plus any of `name` and `description`. Omitted fields stay unchanged, and `description: null` removes the description. It needs `unkey:v1:<workspace_id>:projects/<project_id>/rbac/roles/<role_id>#write`. A name that another role already uses fails with HTTP 409. Keys keep the role after a rename, but requests that name the role, such as `keys.addRoles`, must use the new name. Key verification can return the old name for a short time while caches refresh.

### Change a role's permissions

`permissions.setRolePermissions` takes `role` (ID or name) and the full list of slugs. Anything not in the list is removed, and `[]` empties the role. (The older `roleId` field still works but is deprecated. Send one or the other.) New slugs are created under the same rule as `createRole`. Every key with the role picks up the change within about 10 seconds, and a few verifications just after that can still see the old permissions. [Verifying keys](/api-management/keys/verifying-keys#how-quickly-changes-take-effect) describes the timing.

```bash
curl -X POST https://api.unkey.com/v2/permissions.setRolePermissions \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "role": "editor", "permissions": ["documents.read", "documents.write", "documents.delete"] }'
```

## Designing the set

- **Name permissions after actions**, not customers: `invoices.read`, `invoices.write`, `invoices.void`.
- **Name roles the way you talk to customers**: `viewer`, `editor`, `admin`, or plan names.
- **Attach roles to keys**, and save direct permissions for one-off grants. Then changing what "editor" means is one `setRolePermissions` call, not an update to every key.
- **Use a role for "everything under documents".** An asterisk isn't a wildcard, so list each `documents.*` permission in the role.

## From the dashboard

Open **Authorization** in the sidebar. On the **Permissions** tab, create and edit permissions. On the **Roles** tab, create and edit roles, pick their permissions (or create new ones), and assign the role to keys.

<Frame>
  <img src="/images/dashboard/api-management--authorization-roles-and-permissions--edit-role.png" alt="Edit role dialog showing the keys assigned to the role and its assigned permissions" />
</Frame>

---

### Api Management Cookbook Endpoint Rate Limit (`docs/api-management/cookbook/endpoint-rate-limit.mdx`)

---
title: Per-endpoint rate limits
description: "Give each endpoint its own rate limit budget."
sources:
  - svc/api/openapi/spec/common/RatelimitRequest.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/KeysVerifyKeyRatelimit.yaml
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
  - svc/api/openapi/spec/paths/v2/ratelimit/limit/V2RatelimitLimitRequestBody.yaml
  - internal/services/keys/validation.go
---
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

**Outcome:** a burst of reads can't starve writes, and one expensive endpoint can't exhaust the budget of cheap ones, because each route checks its own <Tooltip tip="Here: limits enforced by keys.verifyKey on a key or identity, or by the standalone ratelimit API. Not a Compute gateway policy.">rate limit</Tooltip>.

<RootKeyPrereq />

## Named limits on the key

Give the key one limit per kind of request, with `autoApply: false`. Those limits are only checked when a verification names them, so each route names its own limit and the others aren't touched.

```bash create a key with two limits
curl -X POST https://api.unkey.com/v2/keys.createKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "apiId": "api_...",
    "ratelimits": [
      { "name": "reads",  "limit": 1000, "duration": 60000, "autoApply": false },
      { "name": "writes", "limit": 100,  "duration": 60000, "autoApply": false }
    ]
  }'
```

```ts name the limit per route
import { Unkey } from "@unkey/api";

const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY ?? "" });

export async function verifyFor(key: string, method: string) {
  const name = method === "GET" ? "reads" : "writes";
  const result = await unkey.keys.verifyKey({ key, ratelimits: [{ name }] });
  return result.data;
}
```

Naming a limit that isn't on the key or its identity fails the request with HTTP 412, so keep the names in one place in your code. To add a limit later, use `keys.updateKey`. Its `ratelimits` field replaces the whole list, so send every limit, not just the new one.

## Weight expensive operations

`cost` is how much of the limit one call uses, 1 by default. If search costs 10, a client can make a tenth as many searches as list calls from the same limit.

To give a route its own limit without saving one on the key, pass both `limit` and `duration` in the entry. The name doesn't have to exist on the key, and the count is kept against the key, not the identity. If you pass only one of the two, it's ignored and the saved limit is used. See [Inline limits](/api-management/ratelimiting/key-and-identity-limits#inline-limits) for the minimum values.

```bash charge 10 against the reads limit
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "key": "sk_live_...", "ratelimits": [{ "name": "reads", "cost": 10 }] }'
```

## Alternative: a namespace per endpoint

When the caller has no API key, or you want the limit in code next to the route, call `ratelimit.limit` with a namespace named after the route. Each route sends its own numbers, and `cost` works the same way.

```ts one namespace per route
const rl = await unkey.ratelimit.limit({
  namespace: "POST /v1/items",
  identifier: userId,
  limit: 100,
  duration: 60_000,
  cost: 1,
});
if (!rl.data.success) {
  return Response.json({ error: "rate limited" }, { status: 429 });
}
```

Namespaces are created on first use. To check a per-user and a per-endpoint limit on the same request, use `ratelimit.multiLimit`. `data.passed` is true only when every check passed.

## Related

- [Creating keys](/api-management/keys/creating-keys) for the `ratelimits` field and its bounds.
- [Verifying keys](/api-management/keys/verifying-keys) for the `ratelimits` request entries and the 412 on unknown names.
- Standalone rate limiting and `multiLimit` are documented under [limit and multiLimit](/api-management/ratelimiting/limit-and-multi-limit).

---

### Api Management Cookbook Fastapi Auth (`docs/api-management/cookbook/fastapi-auth.mdx`)

---
title: FastAPI authentication
description: "Verify API keys in FastAPI with a typed dependency."
sources:
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/py/README.md
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/py/docs/sdks/keys/README.md
---
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

**Outcome:** a route adds `Depends(require_key())` or `Depends(require_key("reports.read"))`, and the dependency <Tooltip tip="Here: keys.verifyKey checking a key your user presented. Not domain verification and not the gateway policy.">verifies</Tooltip> the key, rejects bad requests with the right status, gives the handler the verification data, and adds `X-RateLimit-*` headers.

<RootKeyPrereq />

The root key needs `api.*.verify_key`. Install with `pip install unkey.py fastapi uvicorn`.

## The dependency

`require_key` takes an optional permission query, so each route can ask for its own. FastAPI's `HTTPBearer` reads the header and returns 401 if it's missing.

Catch `httpx.HTTPError` as well as `errors.UnkeyError`. A timeout or connection failure raises an `httpx` error, and without catching it an outage becomes a 500 instead of a 503.

```
/* Detailed source-code truncated for AI context efficiency. */
```

## Routes

```python app/main.py
from typing import Annotated

from fastapi import Depends, FastAPI
from unkey.py import models

from .auth import require_key

app = FastAPI()


@app.get("/items")
def list_items(verification: Annotated[models.V2KeysVerifyKeyResponseData, Depends(require_key())]):
    owner = verification.identity.external_id if verification.identity else None
    return {"items": [], "owner": owner, "credits_left": verification.credits}


@app.get("/reports")
def reports(verification: Annotated[models.V2KeysVerifyKeyResponseData, Depends(require_key("reports.read"))]):
    return {"reports": [], "key": verification.key_id}
```

Headers set on the injected `Response` reach the client when the request succeeds. When the dependency raises an `HTTPException`, such as a 429, those headers are dropped, so pass them in the exception's `headers` argument if you need them on rejections too.

## Async

For an async application, use `async with Unkey(...)` in the lifespan and `await unkey.keys.verify_key_async(...)` in an `async def` dependency. The rest of the code is unchanged.

## Related

- [Python guide](/api-management/guides/python) for creating keys and the basic flow.
- [Verifying keys](/api-management/keys/verifying-keys) for every response field.

---

### Api Management Cookbook Go Middleware (`docs/api-management/cookbook/go-middleware.mdx`)

---
title: Go middleware for net/http, Gin, and Echo
sidebarTitle: "Go middleware"
description: "Drop-in key verification middleware for net/http, Gin, and Echo."
sources:
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/go/v3/keys.go
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/go/v3/unkey.go
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/go/v3/models/components/v2keysverifykeyresponsedata.go
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/go/v3/README.md
---
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

**Outcome:** one `Verify` function with small adapters for net/http, Gin, and Echo. Each reads the bearer key, checks an optional permission query, returns the right status, and puts the verification data on the request.

<RootKeyPrereq />

The root key needs `api.*.verify_key`. Install the SDK with `go get github.com/unkeyed/sdks/api/go/v3`, then the frameworks you use.

## The shared core

```
/* Detailed source-code truncated for AI context efficiency. */
```

`Reason` tells a caller who sent no key that the token is missing, instead of `NOT_FOUND`. `Err` is set only when the call to Unkey failed, so the adapters can return 503.

## Adapters

<CodeGroup>
```go net/http
package auth

import (
	"context"
	"net/http"

	"github.com/unkeyed/sdks/api/go/v3/models/components"
)

type ctxKey struct{}

// Middleware verifies the bearer key and, when permissions is non-empty,
// requires the key to satisfy that permission query.
func (v *Verifier) Middleware(permissions string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			res := v.Verify(r.Context(), r.Header.Get("Authorization"), permissions)
			if res.Err != nil {
				http.Error(w, "authentication unavailable", res.Status)
				return
			}
			if !res.Data.Valid {
				http.Error(w, res.Reason, res.Status)
				return
			}
			next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, res.Data)))
		})
	}
}

// FromContext returns the verification data stored by Middleware.
func FromContext(ctx context.Context) (components.V2KeysVerifyKeyResponseData, bool) {
	d, ok := ctx.Value(ctxKey{}).(components.V2KeysVerifyKeyResponseData)
	return d, ok
}
```
```go Gin
package auth

import (
	"github.com/gin-gonic/gin"
)

func (v *Verifier) Gin(permissions string) gin.HandlerFunc {
	return func(c *gin.Context) {
		res := v.Verify(c.Request.Context(), c.GetHeader("Authorization"), permissions)
		if res.Err != nil {
			c.AbortWithStatusJSON(res.Status, gin.H{"error": "authentication unavailable"})
			return
		}
		if !res.Data.Valid {
			c.AbortWithStatusJSON(res.Status, gin.H{"error": res.Reason})
			return
		}
		c.Set("unkey", res.Data)
		c.Next()
	}
}
```
```go Echo
package auth

import (
	"github.com/labstack/echo/v4"
)

func (v *Verifier) Echo(permissions string) echo.MiddlewareFunc {
	return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) error {
			res := v.Verify(c.Request().Context(), c.Request().Header.Get("Authorization"), permissions)
			if res.Err != nil {
				return c.JSON(res.Status, map[string]string{"error": "authentication unavailable"})
			}
			if !res.Data.Valid {
				return c.JSON(res.Status, map[string]string{"error": res.Reason})
			}
			c.Set("unkey", res.Data)
			return next(c)
		}
	}
}
```
</CodeGroup>

## Using it

```go wiring
verifier := auth.New(os.Getenv("UNKEY_ROOT_KEY"))

// net/http
mux := http.NewServeMux()
mux.Handle("/items", verifier.Middleware("")(http.HandlerFunc(listItems)))
mux.Handle("/admin", verifier.Middleware("admin.read")(http.HandlerFunc(admin)))

// Gin
r := gin.Default()
r.GET("/items", verifier.Gin(""), func(c *gin.Context) {
	data := c.MustGet("unkey").(components.V2KeysVerifyKeyResponseData)
	c.JSON(200, gin.H{"keyId": data.KeyID})
})

// Echo
e := echo.New()
e.GET("/items", listItemsEcho, verifier.Echo(""))
```

To forward rate limit state to clients, read `res.Data.Ratelimits`. Each entry has `Limit`, `Remaining`, and `Reset` (Unix milliseconds) for one checked limit.

## Related

- [Go guide](/api-management/guides/go) for creating keys and the full walk-through.
- [Verifying keys](/api-management/keys/verifying-keys) for the fields on `V2KeysVerifyKeyResponseData`.

---

### Api Management Cookbook Per User Rate Limit (`docs/api-management/cookbook/per-user-rate-limit.mdx`)

---
title: Per-user rate limits
description: "Rate limit a user across all of their keys."
sources:
  - svc/api/openapi/spec/paths/v2/identities/createIdentity/V2IdentitiesCreateIdentityRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/identities/updateIdentity/V2IdentitiesUpdateIdentityRequestBody.yaml
  - svc/api/routes/v2_identities_create_identity/handler.go
  - svc/api/routes/v2_identities_update_identity/handler.go
  - svc/api/routes/v2_keys_create_key/handler.go
  - pkg/rbac/permissions.go
  - svc/api/openapi/spec/common/RatelimitRequest.yaml
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
  - svc/api/openapi/spec/paths/v2/ratelimit/limit/V2RatelimitLimitRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/ratelimit/setOverride/V2RatelimitSetOverrideRequestBody.yaml
  - web/internal/api/src/sdk/ratelimit.ts
---
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

**Outcome:** a user who holds several keys gets one shared <Tooltip tip="Here: limits enforced by keys.verifyKey on a key or identity, or by the standalone ratelimit API. Not a Compute gateway policy.">rate limit</Tooltip>, enforced by `keys.verifyKey` with no extra call, and users on a higher plan get a higher limit without a code change.

<RootKeyPrereq />

## Put the limit on the identity

A limit on a key applies to that key alone. A limit on an identity is shared by all the user's keys. Create the identity with the limit, then create keys with the same `externalId`, and they're linked automatically. `autoApply: true` checks the limit on every verification.

The root key needs `identity.*.create_identity` for the first call, and `api.*.create_key` or `api.<apiId>.create_key` for the second.

```bash create the identity
curl -X POST https://api.unkey.com/v2/identities.createIdentity \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "externalId": "user_123",
    "meta": { "plan": "free" },
    "ratelimits": [
      { "name": "requests", "limit": 100, "duration": 60000, "autoApply": true }
    ]
  }'
```

```bash create a key for that user
curl -X POST https://api.unkey.com/v2/keys.createKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "apiId": "api_...", "externalId": "user_123" }'
```

Your handler doesn't change. `keys.verifyKey` returns `code: RATE_LIMITED` when the shared limit is used up, and `data.ratelimits` has `remaining` and `reset` so you can send `X-RateLimit-*` headers.

```ts verify and forward limit headers
const result = await unkey.keys.verifyKey({ key });
const rl = result.data.ratelimits?.find((r) => r.name === "requests");
const headers = rl
  ? {
      "X-RateLimit-Limit": String(rl.limit),
      "X-RateLimit-Remaining": String(rl.remaining),
      "X-RateLimit-Reset": String(rl.reset),
    }
  : {};
if (!result.data.valid) {
  return Response.json({ error: result.data.code }, { status: result.data.code === "RATE_LIMITED" ? 429 : 401, headers });
}
```

When a user upgrades, call `identities.updateIdentity` with a new `ratelimits` array (needs `identity.*.update_identity`). It replaces the whole list, so send every limit the user should keep. All their keys pick up the change within about 10 seconds.

## Alternative: the standalone ratelimit API

If you're limiting something other than a key holder (a logged-in session, an IP address, a tenant), use `ratelimit.limit`. The `namespace` names what you're limiting, the `identifier` is the user, and you send the limit with each request. The response is always HTTP 200, so read `data.success`.

<CodeGroup>
```bash curl
curl -X POST https://api.unkey.com/v2/ratelimit.limit \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "namespace": "api.requests", "identifier": "user_123", "limit": 100, "duration": 60000 }'
```
```ts TypeScript
const rl = await unkey.ratelimit.limit({
  namespace: "api.requests",
  identifier: userId,
  limit: 100,
  duration: 60_000,
});
if (!rl.data.success) {
  return Response.json({ error: "rate limited" }, { status: 429, headers: { "Retry-After": String(Math.ceil((rl.data.reset - Date.now()) / 1000)) } });
}
```
</CodeGroup>

For plans, use overrides. An override gives one identifier a different limit from the one in the request. Set one when a user upgrades and delete it when they downgrade. `data.overrideId` in the response shows when an override applied. The root key needs `ratelimit.*.limit` to check limits and `ratelimit.*.set_override` to create overrides.

```bash give one user a higher limit
curl -X POST https://api.unkey.com/v2/ratelimit.setOverride \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "namespace": "api.requests", "identifier": "user_123", "limit": 1000, "duration": 60000 }'
```

## Related

- [Creating keys](/api-management/keys/creating-keys) for the `externalId` and `ratelimits` fields.
- [Verifying keys](/api-management/keys/verifying-keys) for the `ratelimits` response entries.
- [Identities](/api-management/identities/overview), [standalone rate limiting](/api-management/ratelimiting/overview), and [overrides](/api-management/ratelimiting/overrides) are documented under API Management.

---

### Api Management Cookbook Tiered Subscriptions (`docs/api-management/cookbook/tiered-subscriptions.mdx`)

---
title: Tiered subscriptions
description: "Model free, pro, and enterprise plans on a key."
sources:
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
  - svc/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyRequestBody.yaml
  - svc/api/openapi/spec/common/RatelimitRequest.yaml
  - svc/api/routes/v2_keys_create_key/handler.go
  - svc/api/routes/v2_keys_set_permissions/handler.go
  - pkg/rbac/permissions.go
---
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

**Outcome:** each plan is a small object in your code, a key carries its plan's limits, credits, permissions, and a `meta.plan` label, and upgrading a customer is one `keys.updateKey` call that takes effect within about ten seconds.

<RootKeyPrereq />

## Define the tiers

Define each plan once and build every key from it. Rate limits cap how often, credits cap how much, permissions unlock features, and `meta` returns the plan name on every verification so your handlers don't need a database lookup.

```ts src/plans.ts
export const PLANS = {
  free: {
    ratelimits: [{ name: "requests", limit: 60, duration: 60_000, autoApply: true }],
    credits: { remaining: 1_000, refill: { interval: "monthly" as const, amount: 1_000, refillDay: 1 } },
    permissions: ["items.read"],
  },
  pro: {
    ratelimits: [{ name: "requests", limit: 600, duration: 60_000, autoApply: true }],
    credits: { remaining: 100_000, refill: { interval: "monthly" as const, amount: 100_000, refillDay: 1 } },
    permissions: ["items.read", "items.write", "exports.create"],
  },
  enterprise: {
    ratelimits: [{ name: "requests", limit: 6_000, duration: 60_000, autoApply: true }],
    credits: null,
    permissions: ["items.read", "items.write", "exports.create", "webhooks.manage"],
  },
};

export type Plan = keyof typeof PLANS;
```

You don't have to create these permissions first. `keys.createKey` creates any that don't exist. `keys.setPermissions` does too, but only if the root key has `rbac.*.create_permission`; otherwise it returns 403. Don't use `*` in slugs, because it isn't a wildcard.

## Create a key for a plan

```ts src/keys.ts
import { Unkey } from "@unkey/api";
import { PLANS, type Plan } from "./plans";

const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY ?? "" });

export async function createKeyFor(customerId: string, plan: Plan) {
  const tier = PLANS[plan];
  const created = await unkey.keys.createKey({
    apiId: process.env.UNKEY_API_ID ?? "",
    externalId: customerId,
    meta: { plan },
    ratelimits: tier.ratelimits,
    permissions: tier.permissions,
    ...(tier.credits ? { credits: tier.credits } : {}),
  });
  return created.data; // key (plaintext, show once) and keyId
}
```

## Read the plan during verification

Gate features with a `permissions` query so Unkey makes the decision. Use `meta.plan` only for softer choices, such as page size.

```ts src/handler.ts
const result = await unkey.keys.verifyKey({ key, permissions: "exports.create" });
if (!result.data.valid) {
  // INSUFFICIENT_PERMISSIONS here means "not on a plan that includes exports"
  return Response.json({ error: result.data.code }, { status: result.data.code === "INSUFFICIENT_PERMISSIONS" ? 403 : 401 });
}
const plan = (result.data.meta as { plan?: string } | undefined)?.plan ?? "free";
const pageSize = plan === "enterprise" ? 1000 : 100;
```

## Change a customer's plan

`keys.updateKey` changes a key in place. Fields you leave out keep their value, `ratelimits` replaces the whole list, and `credits: null` removes the cap and any refill. Use `keys.setPermissions` for permissions. It replaces the key's permissions, so a downgrade removes what the higher plan granted.

<CodeGroup>
```ts TypeScript
export async function changePlan(keyId: string, plan: Plan) {
  const tier = PLANS[plan];
  await unkey.keys.updateKey({
    keyId,
    meta: { plan },
    ratelimits: tier.ratelimits,
    credits: tier.credits,
  });
  await unkey.keys.setPermissions({ keyId, permissions: tier.permissions });
}
```
```bash curl (downgrade to free)
curl -X POST https://api.unkey.com/v2/keys.updateKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "keyId": "key_...",
    "meta": { "plan": "free" },
    "ratelimits": [{ "name": "requests", "limit": 60, "duration": 60000, "autoApply": true }],
    "credits": { "remaining": 1000, "refill": { "interval": "monthly", "amount": 1000, "refillDay": 1 } }
  }'
curl -X POST https://api.unkey.com/v2/keys.setPermissions \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "keyId": "key_...", "permissions": ["items.read"] }'
```
</CodeGroup>

Changes take about 10 seconds to reach verification. Credit changes apply right away. If a customer has several keys, put the plan's rate limits on their identity so all keys share one limit, as shown in [Per-user rate limits](/api-management/cookbook/per-user-rate-limit).

## Related

- [Creating keys](/api-management/keys/creating-keys) for every field and its bounds.
- [Usage-based billing with credits](/api-management/cookbook/usage-billing) for metering inside a plan.
- Roles and permissions are documented under [Roles and permissions](/api-management/authorization/roles-and-permissions).

---

### Api Management Cookbook Usage Billing (`docs/api-management/cookbook/usage-billing.mdx`)

---
title: Usage-based billing with credits
sidebarTitle: "Usage-based billing"
description: "Meter and bill API usage with credits."
sources:
  - svc/api/openapi/spec/common/KeyCreditsData.yaml
  - svc/api/openapi/spec/common/KeyCreditsRefill.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/KeysVerifyKeyCredits.yaml
  - svc/api/openapi/spec/paths/v2/keys/updateCredits/V2KeysUpdateCreditsRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
  - svc/ctrl/internal/db/queries/key_list_for_refill.sql
  - svc/ctrl/internal/db/queries/key_refill_by_ids.sql
---
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

**Outcome:** each customer gets a monthly allowance, heavy operations cost more than light ones, a customer who runs out gets a 402, and a purchased top-up works on the next request. Unkey does the counting.

<RootKeyPrereq />

## Give the key a monthly allowance

`credits.remaining` is the balance and `credits.refill` resets it on a schedule. A monthly refill needs a `refillDay` (in shorter months it runs on the last day). A refill resets the balance to `refill.amount` rather than adding to it, so unused credits don't roll over.

```bash 10,000 requests a month, reset on the 1st
curl -X POST https://api.unkey.com/v2/keys.createKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "apiId": "api_...",
    "externalId": "customer_123",
    "credits": {
      "remaining": 10000,
      "refill": { "interval": "monthly", "amount": 10000, "refillDay": 1 }
    }
  }'
```

## Charge per operation

Each verification spends `credits.cost`, 1 by default. Set the cost per operation: a bulk export might cost 50 and a status check 0. A request that fails any other check, such as a rate limit or permission, spends nothing.

```ts src/billing.ts
import { Unkey } from "@unkey/api";

const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY ?? "" });

const COST: Record<string, number> = {
  "GET /v1/status": 0,
  "GET /v1/items": 1,
  "POST /v1/export": 50,
};

export async function meter(key: string, route: string) {
  const result = await unkey.keys.verifyKey({
    key,
    credits: { cost: COST[route] ?? 1 },
    tags: [`route=${route}`],
  });

  if (!result.data.valid) {
    if (result.data.code === "USAGE_EXCEEDED") {
      // data.credits is the unchanged balance; nothing was deducted.
      return { ok: false as const, status: 402, remaining: result.data.credits ?? 0 };
    }
    return { ok: false as const, status: 401, remaining: result.data.credits };
  }
  // data.credits is the balance after this call. Surface it so clients can plan.
  return { ok: true as const, remaining: result.data.credits, customer: result.data.identity?.externalId };
}
```

Return the balance in a header such as `X-Credits-Remaining` so customers can see it. Analytics records the credits spent per key and identity, so you can build invoices from it.

## Sell top-ups and change plans

`keys.updateCredits` changes the balance and leaves the refill schedule alone. Use `increment` when a customer buys credits, `decrement` to take some back, and `set` for a new balance. `set` with `value: null` makes the key unlimited, for example for an enterprise plan with no cap.

<CodeGroup>
```bash add 5,000 purchased credits
curl -X POST https://api.unkey.com/v2/keys.updateCredits \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "keyId": "key_...", "operation": "increment", "value": 5000 }'
```
```ts TypeScript
await unkey.keys.updateCredits({ keyId, operation: "increment", value: 5000 });
```
</CodeGroup>

To change the monthly allowance, use `keys.updateKey` with a new `credits.refill.amount`. It applies at the next refill.

<Warning>
A refill never lowers a balance, but it doesn't add to one either. If a top-up leaves the customer below their allowance, the next refill wipes it out. If it leaves them above, the refill skips them. To make top-ups always add on top, track them in your own records and re-apply them after the refill day, or sell them as a separate key with no refill.
</Warning>

## Related

- [Credits and refill](/api-management/keys/credits-and-refill) for every field and the refill schedule.
- [Verifying keys](/api-management/keys/verifying-keys) for why credits are checked last.
- [Tiered subscriptions](/api-management/cookbook/tiered-subscriptions) for combining credits with limits and permissions per plan.

---

### Api Management Get Started Concepts (`docs/api-management/get-started/concepts.mdx`)

---
title: Keyspaces, keys, identities, and root keys
sidebarTitle: "Concepts"
description: "The four objects you work with and how they fit together."
sources:
  - web/internal/db/src/schema/apis.ts
  - web/internal/db/src/schema/keyAuth.ts
  - web/internal/db/src/schema/keys.ts
  - web/internal/db/src/schema/identity.ts
  - svc/api/routes/v2_keys_create_key/handler.go
  - internal/services/keys/get.go
  - pkg/rbac/permissions.go
  - web/apps/dashboard/lib/trpc/routers/key/createRootKey.ts
---

To issue and verify API keys for a public API, use API Management. A keyspace holds the keys, `keys.createKey` issues one, and your server calls `keys.verifyKey` on each request. You don't build the hash lookup yourself.

API Management has four objects. A keyspace groups keys. A key is what your user sends you. An identity ties several keys to one user or organization. A root key is what you send to Unkey. Credits, rate limits, permissions, and analytics are all settings on, or views of, these four.

## Keyspace

A keyspace is the container for related keys. You might have one per product, one per <Tooltip tip="An optional free-text label stored on a key in the dashboard, such as live or test. Unkey attaches no behavior to it, and it is unrelated to Compute environments.">environment</Tooltip> such as production and staging, or one per pricing tier. Every key belongs to exactly one keyspace, and you name the keyspace by its API ID (`api_...`) whenever you create a key.

A keyspace sets defaults for new keys (a prefix and a key length). It can also have delete protection and an IP allow list that applies to every key in it. It has a second identifier, the Keyspace ID (`ks_...`), which analytics and the customer portal use. You'll find both IDs on the keyspace's settings page. [Keyspaces](/api-management/keyspaces/overview) explains when to create more than one.

## Key

A key is a random string, with an optional prefix, that you give to a user of your application. Unkey stores only a hash of the key, never the key itself, unless you turn on [recoverable keys](/api-management/keys/recoverable-keys). On every request, your backend sends the key to `keys.verifyKey` and Unkey runs the checks set on it.

Every check is optional: whether the key is enabled, when it expires, how many credits it has left, which rate limits apply, and which permissions and roles it has. A key can also carry JSON metadata that comes back on every verification, so your backend can read a plan tier or feature flag without a database lookup. [Creating keys](/api-management/keys/creating-keys) lists each field with its bounds.

## Identity

An identity is one user, organization, or service account in your system, named by an `externalId` you choose. Keys linked to an identity share its rate limits and metadata. Pass `externalId` when you create a key, and Unkey creates the identity if needed and links the key, so most apps never call the identity endpoints directly. [Identities](/api-management/identities/overview) covers shared rate limits and metadata in detail.

## Root key

A root key authenticates you, not your users. Every call to `api.unkey.com` sends a root key in the `Authorization: Bearer` header, and its permissions decide which calls succeed. Create root keys in the dashboard under **Settings > Root Keys**. Permissions such as `api.*.create_key` or `api.<api_id>.verify_key` apply to every keyspace or to one. See [Root keys](/platform/root-keys/overview) for how permissions are granted and rotated.

## How the objects relate

```text
Workspace
└── Keyspace (api_..., ks_...)
    ├── defaults: prefix, bytes, encrypted storage, IP allow list, delete protection
    └── Key (key_...)
        ├── enabled, expires, credits, rate limits, permissions, roles, meta
        └── Identity (externalId)          shared rate limits and meta across keys

Root key (unkey_...)  authenticates your calls; permissions name keyspaces by api_ id
```

So one request from your user involves two keys: their key, which they send to you, and your root key, which you send to Unkey to verify theirs. Your user's key never grants access to the Unkey API, and your root key should never leave your servers.

## Next steps

<Columns cols={2}>
  <Card title="Issue and verify your first key" icon="rocket" href="/api-management/get-started/quickstart">
    Put the four objects to work in a few minutes.
  </Card>
  <Card title="Keyspaces" icon="folder-tree" href="/api-management/keyspaces/overview">
    Decide how many keyspaces you need and what they store.
  </Card>
</Columns>

---

### Api Management Guides Bun (`docs/api-management/guides/bun.mdx`)

---
title: Bun guide
sidebarTitle: "Bun"
description: "Verify API keys in a Bun.serve handler."
sources:
  - svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiResponseData.yaml
  - pkg/rbac/permissions.go
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
  - web/internal/api/src/sdk/keys.ts
  - web/internal/api/src/models/components/v2keysverifykeyresponsedata.ts
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/ts/README.md
---
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
import { versions } from "/snippets/versions.mdx";

Protect a Bun HTTP server with API keys. `Bun.serve` has one `fetch` handler, so you call a verify helper at the top of it, and `keys.verifyKey` decides whether the request goes on.

<RootKeyPrereq />

<Steps titleSize="h3">
  <Step title="Create a root key and a keyspace">
    In the dashboard, go to **Settings > Root Keys** and create a root key with `api.*.create_api`, `api.*.create_key`, and `api.*.verify_key`. Put it in your server's `UNKEY_ROOT_KEY` environment variable. Never send it to a browser or mobile app.

    Then create a keyspace, which holds your keys. Use **Keyspaces (APIs)** in the dashboard, or `apis.createApi`:

    ```bash create a keyspace
    curl -X POST https://api.unkey.com/v2/apis.createApi \
      -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "name": "my-api" }'
    ```

    Keep the `data.apiId` from the response. You need it to create keys. See [Root keys](/platform/root-keys/overview) for more on root keys.
  </Step>
  <Step title="Install the SDK">
    `@unkey/api` (version {versions.tsApi}) runs unchanged on Bun.

    ```bash
    bun add @unkey/api
    ```
  </Step>
  <Step title="Create a key for a user">
    Create a key from your backend when a user signs up or asks for one. `externalId` links the key to the user, so verification tells you who's calling. `meta` comes back on every verification. You get `data.key` only once: show it to the user and store only `data.keyId`.

    <CodeGroup>
    ```bash curl
    curl -X POST https://api.unkey.com/v2/keys.createKey \
      -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "apiId": "api_...", "prefix": "sk_live", "externalId": "user_123", "meta": { "plan": "free" } }'
    ```
    ```ts TypeScript
    import { Unkey } from "@unkey/api";

    const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY ?? "" });

    const created = await unkey.keys.createKey({
      apiId: process.env.UNKEY_API_ID ?? "",
      prefix: "sk_live",
      externalId: "user_123",
      meta: { plan: "free" },
    });
    // created.data.key is the plaintext, created.data.keyId the handle you keep
    ```
    </CodeGroup>

    Every field is described in [Creating keys](/api-management/keys/creating-keys).
  </Step>
  <Step title="Verify the key in the fetch handler">
    The helper returns either the verification data or a `Response` to send back. Its optional second argument is a permission query, for routes that need more than a valid key. You can read the root key from `Bun.env` or `process.env`.

    ```
/* Detailed source-code truncated for AI context efficiency. */
```

    ```bash
    bun run server.ts
    curl http://localhost:3000/items -H "Authorization: Bearer sk_live_..."
    ```
  </Step>
  <Step title="Handle the outcome codes">
    `keys.verifyKey` returns HTTP 200 for every outcome. Check `data.valid`, then `data.code` for the reason, and return the matching status from your API:

    | `data.code` | Meaning | Return |
    | --- | --- | --- |
    | `VALID` | Every check passed. | continue |
    | `NOT_FOUND` | No such key, or your root key may not verify this keyspace. | 401 |
    | `DISABLED` | The key was disabled. | 401 |
    | `EXPIRED` | The key's expiry passed. | 401 |
    | `FORBIDDEN` | Client IP not on the keyspace allow list, or the workspace is disabled. | 403 |
    | `INSUFFICIENT_PERMISSIONS` | The `permissions` query was not satisfied. | 403 |
    | `RATE_LIMITED` | A <Tooltip tip="Here: limits enforced by keys.verifyKey on a key or identity, or by the standalone ratelimit API. Not a Compute gateway policy.">rate limit</Tooltip> on the key or its identity was exceeded. | 429 |
    | `USAGE_EXCEEDED` | The key has no credits left. | 402 or 429 |

    Remaining credits are in `data.credits`, and each checked rate limit is in `data.ratelimits` with `remaining` and `reset`. The call only fails with an HTTP error (which the SDKs throw) when the call itself is wrong, such as a bad root key or a malformed body. See [Verifying keys](/api-management/keys/verifying-keys) for every field.
  </Step>
  <Step title="Next steps">
    <Columns cols={2}>
      <Card title="Verifying keys" href="/api-management/keys/verifying-keys" icon="key">Every request field, the order of checks, and every response field.</Card>
      <Card title="Credits and refill" href="/api-management/keys/credits-and-refill" icon="coins">Meter usage per key and refill balances on a schedule.</Card>
      <Card title="Cookbook" href="/api-management/cookbook/index" icon="book">Copy-ready recipes for rate limits, billing, and subscription tiers.</Card>
    </Columns>
  </Step>
</Steps>

---

### Api Management Guides Cloudflare Workers (`docs/api-management/guides/cloudflare-workers.mdx`)

---
title: Cloudflare Workers guide
sidebarTitle: "Cloudflare Workers"
description: "Verify API keys at the edge in a Cloudflare Worker."
sources:
  - svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiResponseData.yaml
  - pkg/rbac/permissions.go
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
  - web/internal/api/src/sdk/keys.ts
  - web/internal/api/src/models/components/v2keysverifykeyresponsedata.ts
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/ts/README.md
---
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
import { versions } from "/snippets/versions.mdx";

Protect a Cloudflare Worker with API keys. The Worker's `fetch` handler calls `keys.verifyKey` on each request. Store the root key as a Worker secret, never in `wrangler.toml`.

<RootKeyPrereq />

<Steps titleSize="h3">
  <Step title="Create a root key and a keyspace">
    In the dashboard, go to **Settings > Root Keys** and create a root key with `api.*.create_api`, `api.*.create_key`, and `api.*.verify_key`. Export it as `UNKEY_ROOT_KEY` in your shell for the `curl` commands. You'll store it as a Worker secret in step 2. Never send it to a browser or mobile app.

    Then create a keyspace, which holds your keys. Use **Keyspaces (APIs)** in the dashboard, or `apis.createApi`:

    ```bash create a keyspace
    curl -X POST https://api.unkey.com/v2/apis.createApi \
      -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "name": "my-api" }'
    ```

    Keep the `data.apiId` from the response. You need it to create keys. See [Root keys](/platform/root-keys/overview) for more on root keys.
  </Step>
  <Step title="Install the SDK and store the root key">
    `@unkey/api` (version {versions.tsApi}) runs on Workers with no polyfills.

    ```bash
    npm install @unkey/api
    npx wrangler secret put UNKEY_ROOT_KEY
    ```

    Your code reads it as `env.UNKEY_ROOT_KEY`. If your Worker also creates keys, add the keyspace ID from step 1 as an `UNKEY_API_ID` variable in `wrangler.toml`. It isn't secret.
  </Step>
  <Step title="Create a key for a user">
    Create a key when a user signs up or asks for one. `externalId` links the key to the user, so verification tells you who's calling. `meta` comes back on every verification. You get `data.key` only once: show it to the user and store only `data.keyId`.

    Workers have no `process.env`, so read the secret and keyspace ID from the handler's `env` argument.

    <CodeGroup>
    ```bash curl
    curl -X POST https://api.unkey.com/v2/keys.createKey \
      -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "apiId": "api_...", "prefix": "sk_live", "externalId": "user_123", "meta": { "plan": "free" } }'
    ```
    ```ts src/issue.ts
    import { Unkey } from "@unkey/api";

    interface Env {
      UNKEY_ROOT_KEY: string;
      UNKEY_API_ID: string;
    }

    // Call this from a route your own session check already protects.
    export async function issueKey(env: Env, userId: string): Promise<string> {
      const unkey = new Unkey({ rootKey: env.UNKEY_ROOT_KEY });

      const created = await unkey.keys.createKey({
        apiId: env.UNKEY_API_ID,
        prefix: "sk_live",
        externalId: userId,
        meta: { plan: "free" },
      });
      // created.data.key is the plaintext, created.data.keyId the handle you keep
      return created.data.key;
    }
    ```
    </CodeGroup>

    Every field is described in [Creating keys](/api-management/keys/creating-keys).
  </Step>
  <Step title="Verify the key in the fetch handler">
    `env` is only available inside the handler, so create the client there. The code below creates it once and reuses it.

    ```
/* Detailed source-code truncated for AI context efficiency. */
```

    ```bash
    npx wrangler dev
    curl http://localhost:8787/ -H "Authorization: Bearer sk_live_..."
    ```

    Prefer a router? The [Hono guide](/api-management/guides/hono) runs unchanged on Workers. Read the root key from `c.env.UNKEY_ROOT_KEY` instead of `process.env`.
  </Step>
  <Step title="Handle the outcome codes">
    `keys.verifyKey` returns HTTP 200 for every outcome. Check `data.valid`, then `data.code` for the reason, and return the matching status from your API:

    | `data.code` | Meaning | Return |
    | --- | --- | --- |
    | `VALID` | Every check passed. | continue |
    | `NOT_FOUND` | No such key, or your root key may not verify this keyspace. | 401 |
    | `DISABLED` | The key was disabled. | 401 |
    | `EXPIRED` | The key's expiry passed. | 401 |
    | `FORBIDDEN` | Client IP not on the keyspace allow list, or the workspace is disabled. | 403 |
    | `INSUFFICIENT_PERMISSIONS` | The `permissions` query was not satisfied. | 403 |
    | `RATE_LIMITED` | A <Tooltip tip="Here: limits enforced by keys.verifyKey on a key or identity, or by the standalone ratelimit API. Not a Compute gateway policy.">rate limit</Tooltip> on the key or its identity was exceeded. | 429 |
    | `USAGE_EXCEEDED` | The key has no credits left. | 402 or 429 |

    Remaining credits are in `data.credits`, and each checked rate limit is in `data.ratelimits` with `remaining` and `reset`. The call only fails with an HTTP error (which the SDKs throw) when the call itself is wrong, such as a bad root key or a malformed body. See [Verifying keys](/api-management/keys/verifying-keys) for every field.
  </Step>
  <Step title="Next steps">
    <Columns cols={2}>
      <Card title="Verifying keys" href="/api-management/keys/verifying-keys" icon="key">Every request field, the order of checks, and every response field.</Card>
      <Card title="Credits and refill" href="/api-management/keys/credits-and-refill" icon="coins">Meter usage per key and refill balances on a schedule.</Card>
      <Card title="Cookbook" href="/api-management/cookbook/index" icon="book">Copy-ready recipes for rate limits, billing, and subscription tiers.</Card>
    </Columns>
  </Step>
</Steps>

---

### Api Management Guides Express (`docs/api-management/guides/express.mdx`)

---
title: Express guide
sidebarTitle: "Express"
description: "Protect Express routes with API key middleware."
sources:
  - svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiResponseData.yaml
  - pkg/rbac/permissions.go
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
  - web/internal/api/src/sdk/keys.ts
  - web/internal/api/src/models/components/v2keysverifykeyresponsedata.ts
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/ts/README.md
---
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
import { versions } from "/snippets/versions.mdx";

Protect Express routes with API keys. A middleware reads the bearer token, calls `keys.verifyKey`, rejects bad keys with the right status, and puts the result on `res.locals.unkey` for your handlers.

<RootKeyPrereq />

<Steps titleSize="h3">
  <Step title="Create a root key and a keyspace">
    In the dashboard, go to **Settings > Root Keys** and create a root key with `api.*.create_api`, `api.*.create_key`, and `api.*.verify_key`. Put it in your server's `UNKEY_ROOT_KEY` environment variable. Never send it to a browser or mobile app.

    Then create a keyspace, which holds your keys. Use **Keyspaces (APIs)** in the dashboard, or `apis.createApi`:

    ```bash create a keyspace
    curl -X POST https://api.unkey.com/v2/apis.createApi \
      -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "name": "my-api" }'
    ```

    Keep the `data.apiId` from the response. You need it to create keys. See [Root keys](/platform/root-keys/overview) for more on root keys.
  </Step>
  <Step title="Install the SDK">
    `@unkey/api` (version {versions.tsApi}) is the client for the whole API.

    <CodeGroup>
    ```bash npm
    npm install @unkey/api express
    ```
    ```bash pnpm
    pnpm add @unkey/api express
    ```
    </CodeGroup>
  </Step>
  <Step title="Create a key for a user">
    Create a key from your backend when a user signs up or asks for one. `externalId` links the key to the user, so verification tells you who's calling. `meta` comes back on every verification. You get `data.key` only once: show it to the user and store only `data.keyId`.

    <CodeGroup>
    ```bash curl
    curl -X POST https://api.unkey.com/v2/keys.createKey \
      -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "apiId": "api_...", "prefix": "sk_live", "externalId": "user_123", "meta": { "plan": "free" } }'
    ```
    ```ts TypeScript
    import { Unkey } from "@unkey/api";

    const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY ?? "" });

    const created = await unkey.keys.createKey({
      apiId: process.env.UNKEY_API_ID ?? "",
      prefix: "sk_live",
      externalId: "user_123",
      meta: { plan: "free" },
    });
    // created.data.key is the plaintext, created.data.keyId the handle you keep
    ```
    </CodeGroup>

    Every field is described in [Creating keys](/api-management/keys/creating-keys).
  </Step>
  <Step title="Verify the key in a middleware">
    The middleware <Tooltip tip="Here: keys.verifyKey checking a key your user presented. Not domain verification and not the gateway policy.">verifies</Tooltip> the key once per request and puts the result on `res.locals.unkey`. Routes that need extra permissions pass a `permissions` query.

    Keep `next()` outside the `try`. Otherwise an error in a route handler is caught here and answered as a 503 authentication failure.

    ```
/* Detailed source-code truncated for AI context efficiency. */
```

    ```ts src/server.ts
    import express from "express";
    import { requireKey } from "./unkey";

    const app = express();

    app.get("/items", requireKey(), (_req, res) => {
      res.json({ items: [], owner: res.locals.unkey.identity?.externalId });
    });

    app.post("/items", requireKey("items.write"), (_req, res) => {
      res.status(201).json({ ok: true });
    });

    app.listen(3000);
    ```

    ```bash
    curl http://localhost:3000/items -H "Authorization: Bearer sk_live_..."
    ```
  </Step>
  <Step title="Handle the outcome codes">
    `keys.verifyKey` returns HTTP 200 for every outcome. Check `data.valid`, then `data.code` for the reason, and return the matching status from your API:

    | `data.code` | Meaning | Return |
    | --- | --- | --- |
    | `VALID` | Every check passed. | continue |
    | `NOT_FOUND` | No such key, or your root key may not verify this keyspace. | 401 |
    | `DISABLED` | The key was disabled. | 401 |
    | `EXPIRED` | The key's expiry passed. | 401 |
    | `FORBIDDEN` | Client IP not on the keyspace allow list, or the workspace is disabled. | 403 |
    | `INSUFFICIENT_PERMISSIONS` | The `permissions` query was not satisfied. | 403 |
    | `RATE_LIMITED` | A <Tooltip tip="Here: limits enforced by keys.verifyKey on a key or identity, or by the standalone ratelimit API. Not a Compute gateway policy.">rate limit</Tooltip> on the key or its identity was exceeded. | 429 |
    | `USAGE_EXCEEDED` | The key has no credits left. | 402 or 429 |

    Remaining credits are in `data.credits`, and each checked rate limit is in `data.ratelimits` with `remaining` and `reset`. The call only fails with an HTTP error (which the SDKs throw) when the call itself is wrong, such as a bad root key or a malformed body. See [Verifying keys](/api-management/keys/verifying-keys) for every field.
  </Step>
  <Step title="Next steps">
    <Columns cols={2}>
      <Card title="Verifying keys" href="/api-management/keys/verifying-keys" icon="key">Every request field, the order of checks, and every response field.</Card>
      <Card title="Credits and refill" href="/api-management/keys/credits-and-refill" icon="coins">Meter usage per key and refill balances on a schedule.</Card>
      <Card title="Cookbook" href="/api-management/cookbook/index" icon="book">Copy-ready recipes for rate limits, billing, and subscription tiers.</Card>
    </Columns>
  </Step>
</Steps>

---

### Api Management Guides Go (`docs/api-management/guides/go.mdx`)

---
title: Go guide
sidebarTitle: "Go"
description: "Verify API keys in a Go net/http handler."
sources:
  - svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiResponseData.yaml
  - pkg/rbac/permissions.go
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/go/v3/keys.go
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/go/v3/unkey.go
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/go/v3/models/components/v2keysverifykeyresponsedata.go
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/go/v3/README.md
---
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
import { versions } from "/snippets/versions.mdx";

Protect a Go HTTP server with API keys, using the official Go SDK, `github.com/unkeyed/sdks/api/go/v3` (version {versions.goSdk}). For ready-made middleware for `net/http`, Gin, and Echo, see [Go middleware](/api-management/cookbook/go-middleware).

<RootKeyPrereq />

<Steps titleSize="h3">
  <Step title="Create a root key and a keyspace">
    In the dashboard, go to **Settings > Root Keys** and create a root key with `api.*.create_api`, `api.*.create_key`, and `api.*.verify_key`. Put it in your server's `UNKEY_ROOT_KEY` environment variable. Never send it to a browser or mobile app.

    Then create a keyspace, which holds your keys. Use **Keyspaces (APIs)** in the dashboard, or `apis.createApi`:

    ```bash create a keyspace
    curl -X POST https://api.unkey.com/v2/apis.createApi \
      -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "name": "my-api" }'
    ```

    Keep the `data.apiId` from the response. You need it to create keys. See [Root keys](/platform/root-keys/overview) for more on root keys.
  </Step>
  <Step title="Install the SDK">
    ```bash
    go get github.com/unkeyed/sdks/api/go/v3
    ```

    Create the client once with `unkey.New(unkey.WithSecurity(rootKey))`. It's safe to share across goroutines.
  </Step>
  <Step title="Create a key for a user">
    Create a key from your backend when a user signs up or asks for one. `externalId` links the key to the user, so verification tells you who's calling. `meta` comes back on every verification. You get `data.key` only once: show it to the user and store only `data.keyId`.

    <CodeGroup>
    ```bash curl
    curl -X POST https://api.unkey.com/v2/keys.createKey \
      -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "apiId": "api_...", "prefix": "sk_live", "externalId": "user_123", "meta": { "plan": "free" } }'
    ```
    ```go Go
    package main

    import (
    	"context"
    	"fmt"
    	"log"
    	"os"

    	unkey "github.com/unkeyed/sdks/api/go/v3"
    	"github.com/unkeyed/sdks/api/go/v3/models/components"
    )

    func main() {
    	client := unkey.New(unkey.WithSecurity(os.Getenv("UNKEY_ROOT_KEY")))

    	prefix := "sk_live"
    	externalID := "user_123"
    	res, err := client.Keys.CreateKey(context.Background(), components.V2KeysCreateKeyRequestBody{
    		APIID:      os.Getenv("UNKEY_API_ID"),
    		Prefix:     &prefix,
    		ExternalID: &externalID,
    		Meta:       map[string]any{"plan": "free"},
    	})
    	if err != nil {
    		log.Fatal(err)
    	}
    	if res.V2KeysCreateKeyResponseBody == nil {
    		log.Fatal("unkey: empty response body")
    	}
    	// res.V2KeysCreateKeyResponseBody.Data.Key is the plaintext, Data.KeyID the handle you keep
    	fmt.Println(res.V2KeysCreateKeyResponseBody.Data.KeyID)
    }
    ```
    </CodeGroup>

    Every field is described in [Creating keys](/api-management/keys/creating-keys).
  </Step>
  <Step title="Verify the key in a handler">
    The answer is in `res.V2KeysVerifyKeyResponseBody.Data.Valid` and `.Data.Code`. `V2KeysVerifyKeyResponseBody` is a pointer, so check it for `nil` even when `err` is `nil`. Use the `components.Code*` constants to switch on the outcome. An `error` means the call itself failed, not that the key is invalid.

    ```
/* Detailed source-code truncated for AI context efficiency. */
```

    ```bash
    go run . &
    curl http://localhost:3000/items -H "Authorization: Bearer sk_live_..."
    ```
  </Step>
  <Step title="Handle the outcome codes">
    `keys.verifyKey` returns HTTP 200 for every outcome. Check `data.valid`, then `data.code` for the reason, and return the matching status from your API:

    | `data.code` | Meaning | Return |
    | --- | --- | --- |
    | `VALID` | Every check passed. | continue |
    | `NOT_FOUND` | No such key, or your root key may not verify this keyspace. | 401 |
    | `DISABLED` | The key was disabled. | 401 |
    | `EXPIRED` | The key's expiry passed. | 401 |
    | `FORBIDDEN` | Client IP not on the keyspace allow list, or the workspace is disabled. | 403 |
    | `INSUFFICIENT_PERMISSIONS` | The `permissions` query was not satisfied. | 403 |
    | `RATE_LIMITED` | A <Tooltip tip="Here: limits enforced by keys.verifyKey on a key or identity, or by the standalone ratelimit API. Not a Compute gateway policy.">rate limit</Tooltip> on the key or its identity was exceeded. | 429 |
    | `USAGE_EXCEEDED` | The key has no credits left. | 402 or 429 |

    Remaining credits are in `data.credits`, and each checked rate limit is in `data.ratelimits` with `remaining` and `reset`. The call only fails with an HTTP error (which the SDKs throw) when the call itself is wrong, such as a bad root key or a malformed body. See [Verifying keys](/api-management/keys/verifying-keys) for every field.
  </Step>
  <Step title="Next steps">
    <Columns cols={2}>
      <Card title="Verifying keys" href="/api-management/keys/verifying-keys" icon="key">Every request field, the order of checks, and every response field.</Card>
      <Card title="Credits and refill" href="/api-management/keys/credits-and-refill" icon="coins">Meter usage per key and refill balances on a schedule.</Card>
      <Card title="Cookbook" href="/api-management/cookbook/index" icon="book">Copy-ready recipes for rate limits, billing, and subscription tiers.</Card>
    </Columns>
  </Step>
</Steps>

---

### Api Management Guides Hono (`docs/api-management/guides/hono.mdx`)

---
title: Hono guide
sidebarTitle: "Hono"
description: "Protect Hono routes with API keys."
sources:
  - svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiResponseData.yaml
  - pkg/rbac/permissions.go
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
  - web/internal/api/src/sdk/keys.ts
  - web/internal/api/src/models/components/v2keysverifykeyresponsedata.ts
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/ts/README.md
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/hono/README.md
---
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
import { versions } from "/snippets/versions.mdx";

Protect Hono routes with API keys. The code works on Node, Bun, Deno, and Cloudflare Workers. Only how you read the root key changes. Use the `@unkey/hono` middleware to have verification handled for you, or write a few lines with `@unkey/api` to control every response.

<RootKeyPrereq />

<Steps titleSize="h3">
  <Step title="Create a root key and a keyspace">
    In the dashboard, go to **Settings > Root Keys** and create a root key with `api.*.create_api`, `api.*.create_key`, and `api.*.verify_key`. Put it in your server's `UNKEY_ROOT_KEY` environment variable. Never send it to a browser or mobile app.

    Then create a keyspace, which holds your keys. Use **Keyspaces (APIs)** in the dashboard, or `apis.createApi`:

    ```bash create a keyspace
    curl -X POST https://api.unkey.com/v2/apis.createApi \
      -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "name": "my-api" }'
    ```

    Keep the `data.apiId` from the response. You need it to create keys. See [Root keys](/platform/root-keys/overview) for more on root keys.
  </Step>
  <Step title="Install the SDK">
    `@unkey/hono` (version {versions.tsHono}) is the middleware. `@unkey/api` (version {versions.tsApi}) is the client for creating keys and for the hand-written version.

    <CodeGroup>
    ```bash npm
    npm install hono @unkey/hono @unkey/api
    ```
    ```bash bun
    bun add hono @unkey/hono @unkey/api
    ```
    </CodeGroup>
  </Step>
  <Step title="Create a key for a user">
    Create a key from your backend when a user signs up or asks for one. `externalId` links the key to the user, so verification tells you who's calling. `meta` comes back on every verification. You get `data.key` only once: show it to the user and store only `data.keyId`.

    <CodeGroup>
    ```bash curl
    curl -X POST https://api.unkey.com/v2/keys.createKey \
      -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "apiId": "api_...", "prefix": "sk_live", "externalId": "user_123", "meta": { "plan": "free" } }'
    ```
    ```ts TypeScript
    import { Unkey } from "@unkey/api";

    const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY ?? "" });

    const created = await unkey.keys.createKey({
      apiId: process.env.UNKEY_API_ID ?? "",
      prefix: "sk_live",
      externalId: "user_123",
      meta: { plan: "free" },
    });
    // created.data.key is the plaintext, created.data.keyId the handle you keep
    ```
    </CodeGroup>

    Every field is described in [Creating keys](/api-management/keys/creating-keys).
  </Step>
  <Step title="Verify the key in a middleware">
    With `@unkey/hono`, the `unkey()` middleware reads the bearer token, <Tooltip tip="Here: keys.verifyKey checking a key your user presented. Not domain verification and not the gateway policy.">verifies</Tooltip> it, and stores the result in the `unkey` context variable. `handleInvalidKey` decides the response for a bad key.

    `onError` only runs for unexpected responses, such as a 502 from a proxy. A rejected root key, a throttled request, a 500, or a network failure is thrown instead, so catch those in Hono's `app.onError` if you want one response for every authentication failure.

    <CodeGroup>
    ```ts src/index.ts (@unkey/hono)
    import { Hono } from "hono";
    import { type UnkeyContext, unkey } from "@unkey/hono";

    const app = new Hono<{ Variables: { unkey: UnkeyContext } }>();

    app.use(
      "/items/*",
      unkey({
        rootKey: process.env.UNKEY_ROOT_KEY ?? "",
        handleInvalidKey: (c) => c.json({ error: "invalid API key" }, 401),
        onError: (c, err) => {
          console.error("unkey", err.message);
          return c.json({ error: "authentication unavailable" }, 503);
        },
      }),
    );

    app.get("/items/list", (c) => {
      const verification = c.get("unkey");
      return c.json({ items: [], owner: verification.data.identity?.externalId });
    });

    export default app;
    ```
    ```
/* Detailed source-code truncated for AI context efficiency. */
```
    </CodeGroup>

    Keep `next()` outside the `try`. Otherwise an error in a route handler is caught here and answered as an authentication failure.

    The middleware also takes `permissions` (a permission query every key must pass), `tags`, and `getKey` (for keys sent somewhere other than the `Authorization` header).
  </Step>
  <Step title="Handle the outcome codes">
    `keys.verifyKey` returns HTTP 200 for every outcome. Check `data.valid`, then `data.code` for the reason, and return the matching status from your API:

    | `data.code` | Meaning | Return |
    | --- | --- | --- |
    | `VALID` | Every check passed. | continue |
    | `NOT_FOUND` | No such key, or your root key may not verify this keyspace. | 401 |
    | `DISABLED` | The key was disabled. | 401 |
    | `EXPIRED` | The key's expiry passed. | 401 |
    | `FORBIDDEN` | Client IP not on the keyspace allow list, or the workspace is disabled. | 403 |
    | `INSUFFICIENT_PERMISSIONS` | The `permissions` query was not satisfied. | 403 |
    | `RATE_LIMITED` | A <Tooltip tip="Here: limits enforced by keys.verifyKey on a key or identity, or by the standalone ratelimit API. Not a Compute gateway policy.">rate limit</Tooltip> on the key or its identity was exceeded. | 429 |
    | `USAGE_EXCEEDED` | The key has no credits left. | 402 or 429 |

    Remaining credits are in `data.credits`, and each checked rate limit is in `data.ratelimits` with `remaining` and `reset`. The call only fails with an HTTP error (which the SDKs throw) when the call itself is wrong, such as a bad root key or a malformed body. See [Verifying keys](/api-management/keys/verifying-keys) for every field.
  </Step>
  <Step title="Next steps">
    <Columns cols={2}>
      <Card title="Verifying keys" href="/api-management/keys/verifying-keys" icon="key">Every request field, the order of checks, and every response field.</Card>
      <Card title="Credits and refill" href="/api-management/keys/credits-and-refill" icon="coins">Meter usage per key and refill balances on a schedule.</Card>
      <Card title="Cookbook" href="/api-management/cookbook/index" icon="book">Copy-ready recipes for rate limits, billing, and subscription tiers.</Card>
    </Columns>
  </Step>
</Steps>

---

### Api Management Guides Nextjs (`docs/api-management/guides/nextjs.mdx`)

---
title: Next.js guide
sidebarTitle: "Next.js"
description: "Protect a Next.js route handler with an API key."
sources:
  - svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiResponseData.yaml
  - pkg/rbac/permissions.go
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
  - web/internal/api/src/sdk/keys.ts
  - web/internal/api/src/models/components/v2keysverifykeyresponsedata.ts
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/ts/README.md
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/nextjs/README.md
---
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
import { versions } from "/snippets/versions.mdx";

Protect a Next.js App Router route handler with API keys. Your server creates keys for users, and every request to the route is checked with `keys.verifyKey` before your handler runs. Verification needs your root key, so do it in route handlers or server actions, never in client components.

<RootKeyPrereq />

<Steps titleSize="h3">
  <Step title="Create a root key and a keyspace">
    In the dashboard, go to **Settings > Root Keys** and create a root key with `api.*.create_api`, `api.*.create_key`, and `api.*.verify_key`. Put it in your server's `UNKEY_ROOT_KEY` environment variable. Never send it to a browser or mobile app.

    Then create a keyspace, which holds your keys. Use **Keyspaces (APIs)** in the dashboard, or `apis.createApi`:

    ```bash create a keyspace
    curl -X POST https://api.unkey.com/v2/apis.createApi \
      -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "name": "my-api" }'
    ```

    Keep the `data.apiId` from the response. You need it to create keys. See [Root keys](/platform/root-keys/overview) for more on root keys.
  </Step>
  <Step title="Install the SDK">
    `@unkey/api` (version {versions.tsApi}) is the client for the whole API. `@unkey/nextjs` (version {versions.tsNextjs}) wraps a route handler and verifies for you. You only need it for the wrapper in step 4.

    <CodeGroup>
    ```bash npm
    npm install @unkey/api @unkey/nextjs
    ```
    ```bash pnpm
    pnpm add @unkey/api @unkey/nextjs
    ```
    ```bash bun
    bun add @unkey/api @unkey/nextjs
    ```
    </CodeGroup>
  </Step>
  <Step title="Create a key for a user">
    Create a key from your backend when a user signs up or asks for one. `externalId` links the key to the user, so verification tells you who's calling. `meta` comes back on every verification. You get `data.key` only once: show it to the user and store only `data.keyId`.

    <CodeGroup>
    ```bash curl
    curl -X POST https://api.unkey.com/v2/keys.createKey \
      -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "apiId": "api_...", "prefix": "sk_live", "externalId": "user_123", "meta": { "plan": "free" } }'
    ```
    ```ts TypeScript
    import { Unkey } from "@unkey/api";

    const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY ?? "" });

    const created = await unkey.keys.createKey({
      apiId: process.env.UNKEY_API_ID ?? "",
      prefix: "sk_live",
      externalId: "user_123",
      meta: { plan: "free" },
    });
    // created.data.key is the plaintext, created.data.keyId the handle you keep
    ```
    </CodeGroup>

    Every field is described in [Creating keys](/api-management/keys/creating-keys).
  </Step>
  <Step title="Verify the key in a route handler">
    The first handler calls `keys.verifyKey` directly, so you control the response. The second uses `withUnkey`, which reads the bearer token from the `Authorization` header, <Tooltip tip="Here: keys.verifyKey checking a key your user presented. Not domain verification and not the gateway policy.">verifies</Tooltip> it, and puts the result in `req.unkey`. Pass `handleInvalidKey` to choose the response for a bad key.

    <CodeGroup>
    ```
/* Detailed source-code truncated for AI context efficiency. */
```
    ```ts app/api/items/route.ts (@unkey/nextjs)
    import { withUnkey } from "@unkey/nextjs";
    import { NextResponse } from "next/server";

    export const GET = withUnkey(
      async (req) => {
        // req.unkey holds the verification response; the key is already valid here
        return NextResponse.json({ items: [], keyId: req.unkey?.data.keyId });
      },
      {
        rootKey: process.env.UNKEY_ROOT_KEY ?? "",
        handleInvalidKey: () => NextResponse.json({ error: "invalid API key" }, { status: 401 }),
        onError: (_req, err) => {
          console.error("unkey", err.message);
          return NextResponse.json({ error: "authentication unavailable" }, { status: 503 });
        },
      },
    );
    ```
    </CodeGroup>

    <Warning>
    `withUnkey` only calls `onError` for unexpected responses, such as a 502 from a proxy. A rejected root key, a throttled request, a 500, or a network failure is thrown instead, so your route fails with an unhandled error rather than a 503. The hand-written version catches all of these and returns 503.
    </Warning>

    Test it with the key from step 3:

    ```bash
    curl http://localhost:3000/api/items -H "Authorization: Bearer sk_live_..."
    ```
  </Step>
  <Step title="Handle the outcome codes">
    `keys.verifyKey` returns HTTP 200 for every outcome. Check `data.valid`, then `data.code` for the reason, and return the matching status from your API:

    | `data.code` | Meaning | Return |
    | --- | --- | --- |
    | `VALID` | Every check passed. | continue |
    | `NOT_FOUND` | No such key, or your root key may not verify this keyspace. | 401 |
    | `DISABLED` | The key was disabled. | 401 |
    | `EXPIRED` | The key's expiry passed. | 401 |
    | `FORBIDDEN` | Client IP not on the keyspace allow list, or the workspace is disabled. | 403 |
    | `INSUFFICIENT_PERMISSIONS` | The `permissions` query was not satisfied. | 403 |
    | `RATE_LIMITED` | A <Tooltip tip="Here: limits enforced by keys.verifyKey on a key or identity, or by the standalone ratelimit API. Not a Compute gateway policy.">rate limit</Tooltip> on the key or its identity was exceeded. | 429 |
    | `USAGE_EXCEEDED` | The key has no credits left. | 402 or 429 |

    Remaining credits are in `data.credits`, and each checked rate limit is in `data.ratelimits` with `remaining` and `reset`. The call only fails with an HTTP error (which the SDKs throw) when the call itself is wrong, such as a bad root key or a malformed body. See [Verifying keys](/api-management/keys/verifying-keys) for every field.
  </Step>
  <Step title="Next steps">
    <Columns cols={2}>
      <Card title="Verifying keys" href="/api-management/keys/verifying-keys" icon="key">Every request field, the order of checks, and every response field.</Card>
      <Card title="Credits and refill" href="/api-management/keys/credits-and-refill" icon="coins">Meter usage per key and refill balances on a schedule.</Card>
      <Card title="Cookbook" href="/api-management/cookbook/index" icon="book">Copy-ready recipes for rate limits, billing, and subscription tiers.</Card>
    </Columns>
  </Step>
</Steps>

---

### Api Management Guides Python (`docs/api-management/guides/python.mdx`)

---
title: Python guide
sidebarTitle: "Python"
description: "Verify API keys from Python with unkey.py."
sources:
  - svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiResponseData.yaml
  - pkg/rbac/permissions.go
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/py/README.md
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/py/docs/sdks/keys/README.md
---
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
import { versions } from "/snippets/versions.mdx";

Protect a Python API with API keys, using the official SDK, `unkey.py` (version {versions.pySdk}). The examples use FastAPI, but the client calls are the same in Flask or Django. For a fuller FastAPI setup, see [FastAPI authentication](/api-management/cookbook/fastapi-auth).

<RootKeyPrereq />

<Steps titleSize="h3">
  <Step title="Create a root key and a keyspace">
    In the dashboard, go to **Settings > Root Keys** and create a root key with `api.*.create_api`, `api.*.create_key`, and `api.*.verify_key`. Put it in your server's `UNKEY_ROOT_KEY` environment variable. Never send it to a browser or mobile app.

    Then create a keyspace, which holds your keys. Use **Keyspaces (APIs)** in the dashboard, or `apis.createApi`:

    ```bash create a keyspace
    curl -X POST https://api.unkey.com/v2/apis.createApi \
      -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "name": "my-api" }'
    ```

    Keep the `data.apiId` from the response. You need it to create keys. See [Root keys](/platform/root-keys/overview) for more on root keys.
  </Step>
  <Step title="Install the SDK">
    <CodeGroup>
    ```bash pip
    pip install unkey.py fastapi uvicorn
    ```
    ```bash uv
    uv add unkey.py fastapi uvicorn
    ```
    </CodeGroup>

    Create the client with `Unkey(root_key=...)`. It works as a context manager, and every method has an `_async` version for `asyncio`.
  </Step>
  <Step title="Create a key for a user">
    Create a key from your backend when a user signs up or asks for one. `externalId` links the key to the user, so verification tells you who's calling. `meta` comes back on every verification. You get `data.key` only once: show it to the user and store only `data.keyId`.

    <CodeGroup>
    ```bash curl
    curl -X POST https://api.unkey.com/v2/keys.createKey \
      -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "apiId": "api_...", "prefix": "sk_live", "externalId": "user_123", "meta": { "plan": "free" } }'
    ```
    ```python Python
    import os

    from unkey.py import Unkey

    with Unkey(root_key=os.environ["UNKEY_ROOT_KEY"]) as unkey:
        created = unkey.keys.create_key(
            api_id=os.environ["UNKEY_API_ID"],
            prefix="sk_live",
            external_id="user_123",
            meta={"plan": "free"},
        )
        # created.data.key is the plaintext, created.data.key_id the handle you keep
        print(created.data.key_id)
    ```
    </CodeGroup>

    Every field is described in [Creating keys](/api-management/keys/creating-keys).
  </Step>
  <Step title="Verify the key in a dependency">
    The dependency reads the bearer token, calls `keys.verify_key`, and raises `HTTPException` with the right status when `data.valid` is false. If the call itself fails, it raises `errors.UnkeyError`, or an `httpx` error for a timeout or connection failure. Catch both and return 503, so callers can tell an outage from a rejected key.

    ```python main.py
    import os

    import httpx
    from fastapi import Depends, FastAPI, HTTPException, Request
    from unkey.py import Unkey, errors

    app = FastAPI()
    unkey = Unkey(root_key=os.environ["UNKEY_ROOT_KEY"])

    STATUS = {
        "RATE_LIMITED": 429,
        "USAGE_EXCEEDED": 402,
        "FORBIDDEN": 403,
        "INSUFFICIENT_PERMISSIONS": 403,
    }


    def require_key(request: Request):
        auth = request.headers.get("authorization", "")
        if not auth.startswith("Bearer "):
            raise HTTPException(status_code=401, detail="missing API key")
        key = auth[len("Bearer "):]

        try:
            res = unkey.keys.verify_key(
                key=key,
                tags=[f"endpoint={request.url.path}", f"method={request.method}"],
            )
        except (errors.UnkeyError, httpx.HTTPError) as e:
            # The call failed (bad root key, throttle, outage), not the user's key.
            raise HTTPException(status_code=503, detail="authentication unavailable") from e

        if not res.data.valid:
            code = res.data.code
            raise HTTPException(status_code=STATUS.get(code, 401), detail=code)
        return res.data


    @app.get("/items")
    def list_items(verification=Depends(require_key)):
        owner = verification.identity.external_id if verification.identity else None
        return {"items": [], "owner": owner}
    ```

    For an async app, create the client with `async with Unkey(...)` and call `await unkey.keys.verify_key_async(key=key)`.

    ```bash
    uvicorn main:app --port 3000
    curl http://localhost:3000/items -H "Authorization: Bearer sk_live_..."
    ```
  </Step>
  <Step title="Handle the outcome codes">
    `keys.verifyKey` returns HTTP 200 for every outcome. Check `data.valid`, then `data.code` for the reason, and return the matching status from your API:

    | `data.code` | Meaning | Return |
    | --- | --- | --- |
    | `VALID` | Every check passed. | continue |
    | `NOT_FOUND` | No such key, or your root key may not verify this keyspace. | 401 |
    | `DISABLED` | The key was disabled. | 401 |
    | `EXPIRED` | The key's expiry passed. | 401 |
    | `FORBIDDEN` | Client IP not on the keyspace allow list, or the workspace is disabled. | 403 |
    | `INSUFFICIENT_PERMISSIONS` | The `permissions` query was not satisfied. | 403 |
    | `RATE_LIMITED` | A <Tooltip tip="Here: limits enforced by keys.verifyKey on a key or identity, or by the standalone ratelimit API. Not a Compute gateway policy.">rate limit</Tooltip> on the key or its identity was exceeded. | 429 |
    | `USAGE_EXCEEDED` | The key has no credits left. | 402 or 429 |

    Remaining credits are in `data.credits`, and each checked rate limit is in `data.ratelimits` with `remaining` and `reset`. The call only fails with an HTTP error (which the SDKs throw) when the call itself is wrong, such as a bad root key or a malformed body. See [Verifying keys](/api-management/keys/verifying-keys) for every field.
  </Step>
  <Step title="Next steps">
    <Columns cols={2}>
      <Card title="Verifying keys" href="/api-management/keys/verifying-keys" icon="key">Every request field, the order of checks, and every response field.</Card>
      <Card title="Credits and refill" href="/api-management/keys/credits-and-refill" icon="coins">Meter usage per key and refill balances on a schedule.</Card>
      <Card title="Cookbook" href="/api-management/cookbook/index" icon="book">Copy-ready recipes for rate limits, billing, and subscription tiers.</Card>
    </Columns>
  </Step>
</Steps>

---

### Api Management Identities Shared Rate Limits (`docs/api-management/identities/shared-rate-limits.mdx`)

---
title: Shared rate limits across keys
sidebarTitle: "Shared rate limits"
description: "Put one rate limit on a user so all of their keys share it."
sources:
  - svc/api/openapi/spec/paths/v2/identities/createIdentity/V2IdentitiesCreateIdentityRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/identities/updateIdentity/V2IdentitiesUpdateIdentityRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
  - svc/api/routes/v2_identities_create_identity/handler.go
  - svc/api/routes/v2_keys_create_key/handler.go
  - svc/api/routes/v2_keys_verify_key/handler.go
  - internal/services/keys/get.go
  - internal/services/keys/validation.go
  - web/internal/api/src/sdk/identities.ts
---

import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

A rate limit on a key counts that key alone. If a user has three keys at 100 requests per minute each, they can make 300. Put the limit on the user's identity instead, and all their keys share the same 100. Here's how to set it up.

<RootKeyPrereq />

The root key needs `identity.*.create_identity` (or `identity.*.update_identity` for an existing identity), `api.*.create_key`, and `api.*.verify_key`.

<Steps titleSize="h3">
  <Step title="Create the identity with a limit">
    `autoApply: true` makes the limit count on every verification without the caller naming it.

    <CodeGroup>
    ```bash cURL
    curl -X POST https://api.unkey.com/v2/identities.createIdentity \
      -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "externalId": "user_123",
        "ratelimits": [
          { "name": "requests", "limit": 100, "duration": 60000, "autoApply": true }
        ]
      }'
    ```
    ```typescript TypeScript
    const { data } = await unkey.identities.createIdentity({
      externalId: "user_123",
      ratelimits: [
        { name: "requests", limit: 100, duration: 60_000, autoApply: true },
      ],
    });
    console.log(data.identityId);
    ```
    </CodeGroup>

    If the identity already exists, for example because a key was created with this `externalId` earlier, the call returns HTTP 409 `err:unkey:data:identity_already_exists`. Use `identities.updateIdentity` with the same `ratelimits` array instead.
  </Step>

  <Step title="Create keys that belong to it">
    Any key created with the same `externalId` is linked to the identity.

    <CodeGroup>
    ```bash cURL
    curl -X POST https://api.unkey.com/v2/keys.createKey \
      -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "apiId": "api_...", "externalId": "user_123", "name": "Production" }'
    ```
    ```typescript TypeScript
    await unkey.keys.createKey({ apiId: "api_...", externalId: "user_123", name: "Production" });
    await unkey.keys.createKey({ apiId: "api_...", externalId: "user_123", name: "Staging" });
    ```
    </CodeGroup>
  </Step>

  <Step title="Verify any of the keys">
    The identity limit is auto-applied, so a plain verification enforces it. Requests through the production key and the staging key both draw down the same 100 per minute.

    <CodeGroup>
    ```bash cURL
    curl -X POST https://api.unkey.com/v2/keys.verifyKey \
      -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "key": "sk_..." }'
    ```
    ```typescript TypeScript
    const { data } = await unkey.keys.verifyKey({ key: incomingKey });

    if (!data.valid && data.code === "RATE_LIMITED") {
      // the user exceeded 100 requests per minute across all their keys
    }
    console.log(data.identity?.externalId); // "user_123"
    ```
    </CodeGroup>

    `data.ratelimits` shows the limits that were checked, with `remaining` and `reset`.
  </Step>
</Steps>

## Several limits on one identity

Add more than one limit when different operations need different budgets. A limit with `autoApply: false` is only checked when a verification names it, so an expensive endpoint can have its own budget.

```bash
curl -X POST https://api.unkey.com/v2/identities.updateIdentity \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "identity": "user_123",
    "ratelimits": [
      { "name": "requests", "limit": 500, "duration": 3600000, "autoApply": true },
      { "name": "tokens", "limit": 20000, "duration": 86400000, "autoApply": false }
    ]
  }'
```

Then spend 150 tokens on one call:

```bash
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "key": "sk_...", "ratelimits": [ { "name": "tokens", "cost": 150 } ] }'
```

Both `requests` (auto-applied) and `tokens` are checked together. If either is exceeded, the verification returns `RATE_LIMITED` and neither is consumed.

## Exceptions for one key

If a key has a limit with the same `name` as one on its identity, the key's limit wins for that key. Use this to give one integration a higher limit than the user's other keys. Details are in [Key and identity rate limits](/api-management/ratelimiting/key-and-identity-limits).

---

### Api Management Keys Creating Keys (`docs/api-management/keys/creating-keys.mdx`)

---
title: Creating keys
description: "Every field you can set when you create a key, with its limits."
sources:
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
  - svc/api/openapi/spec/common/KeyCreditsData.yaml
  - svc/api/openapi/spec/common/KeyCreditsRefill.yaml
  - svc/api/openapi/spec/common/RatelimitRequest.yaml
  - svc/api/routes/v2_keys_create_key/handler.go
  - svc/api/config.go
  - internal/services/keys/create.go
  - web/internal/db/src/schema/keyAuth.ts
  - web/internal/db/src/schema/keys.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/_components/create-key/create-key.schema.ts
---

import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

Create a key with `keys.createKey`. The only required field is the keyspace's API ID. Everything else is optional and you can change it later with `keys.updateKey`. You get the key back once, in the response.

<RootKeyPrereq />

The root key needs `api.*.create_key` or `api.<api_id>.create_key`. Creating a recoverable key also needs `api.*.encrypt_key` or `api.<api_id>.encrypt_key`. Without it you get HTTP 404 [`err:unkey:data:api_not_found`](/errors/unkey/data/api_not_found), not a permission error, so a missing grant looks like a missing API. See [Root key permissions](/platform/root-keys/permissions).

## Request fields

<ParamField body="apiId" type="string" required>
  The keyspace that owns the key, by API ID (`api_...`). 3 to 255 characters matching `^[a-zA-Z0-9_]+$`. You can't move a key to another keyspace later.
</ParamField>

<ParamField body="prefix" type="string">
  1 to 16 characters matching `^[a-zA-Z0-9_]+$`. Becomes the start of the key as `<prefix>_<random>`, so users can tell keys apart in logs. When omitted, the key uses the keyspace's default prefix if one is set, and otherwise has no prefix. Don't put secrets or customer names in a prefix. It's visible wherever the key is.
</ParamField>

<ParamField body="name" type="string">
  1 to 255 characters. An internal label returned by `keys.getKey`, `apis.listKeys`, and `keys.verifyKey`. Unkey never shows it to your users.
</ParamField>

<ParamField body="byteLength" type="integer" default="16">
  16 to 255 random bytes, base58-encoded into the key. When omitted, the key uses the keyspace's default bytes value, or 16 if that's unset. 16 bytes gives 2^128 possibilities, and 32 is plenty for the most sensitive uses.
</ParamField>

<ParamField body="externalId" type="string">
  1 to 255 characters matching `^[a-zA-Z0-9_.-]+$`: your identifier for the user, organization, or tenant that owns the key. Unkey creates an identity with this `externalId` if none exists and links the key to it, so every verification returns `identity.externalId` and any metadata or shared rate limits set on the identity. See [Identities](/api-management/identities/overview).
</ParamField>

<ParamField body="meta" type="object">
  Arbitrary JSON returned in full on every verification. At most 100 top-level properties. Keep it small: it travels with every `keys.verifyKey` response, and the whole request body is capped at 10 MiB. Don't store secrets here. See [Metadata and tags](/api-management/keys/metadata-and-tags).
</ParamField>

<ParamField body="roles" type="string[]">
  Up to 100 role names, each 1 to 128 characters. Every role must already exist in the workspace or the request fails. Roles bundle permissions, and the key gains all of them.
</ParamField>

<ParamField body="permissions" type="string[]">
  Up to 1000 permission slugs, each 1 to 128 characters matching `^[a-zA-Z0-9_:\-\.\*]+$`. Added to the key on top of what its roles grant. Unlike roles, a slug that doesn't exist yet is created for you. An asterisk is a literal character, not a wildcard.
</ParamField>

<ParamField body="expires" type="integer">
  Unix timestamp in milliseconds, at most `4102444800000` (1 January 2100). After this instant verification returns `code: EXPIRED`. Omit for a key that never expires. See [Key expiration](/api-management/keys/expiration).
</ParamField>

<ParamField body="credits" type="object">
  Usage metering. `credits.remaining` (integer, 0 or more) is the number of verifications the key can still afford. `credits.refill` optionally restores it on a schedule with `interval` (`daily` or `monthly`), `amount` (1 or more), and `refillDay` (1 to 31, required for `monthly`). `remaining` must be set whenever `refill` is set. Omit the whole object for unlimited usage. See [Credits and refill](/api-management/keys/credits-and-refill).
</ParamField>

<ParamField body="ratelimits" type="object[]">
  Up to 50 named rate limits, each with `name` (3 to 128 characters), `limit` (1 or more), `duration` in milliseconds (1000 or more), and `autoApply` (boolean, required). Limits with `autoApply: true` are checked on every verification. The others are checked only when a verification names them. See [Key and identity rate limits](/api-management/ratelimiting/key-and-identity-limits).
</ParamField>

<ParamField body="enabled" type="boolean" default="true">
  Set to `false` to create the key in a disabled state. Verification returns `code: DISABLED` until you enable it. See [Disabling and deleting keys](/api-management/keys/enable-disable-delete).
</ParamField>

<ParamField body="recoverable" type="boolean" default="false">
  Store an encrypted copy of the key so `keys.getKey` and `apis.listKeys` can show it later with `decrypt: true`. If the keyspace doesn't have encrypted storage turned on, you get HTTP 412 [`err:unkey:application:precondition_failed`](/errors/unkey/application/precondition_failed). If the root key lacks the encrypt permission, you get HTTP 404 [`err:unkey:data:api_not_found`](/errors/unkey/data/api_not_found). See [Recoverable keys](/api-management/keys/recoverable-keys).
</ParamField>

## Example

```bash
curl -X POST https://api.unkey.com/v2/keys.createKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "apiId": "api_...",
    "prefix": "sk_live",
    "name": "Acme production",
    "externalId": "org_acme",
    "meta": { "plan": "pro" },
    "expires": 1767225600000,
    "credits": {
      "remaining": 10000,
      "refill": { "interval": "monthly", "amount": 10000, "refillDay": 1 }
    },
    "ratelimits": [
      { "name": "requests", "limit": 100, "duration": 60000, "autoApply": true }
    ],
    "permissions": ["documents.read", "documents.write"]
  }'
```

## Response

```json
{
  "meta": { "requestId": "req_..." },
  "data": {
    "keyId": "key_...",
    "key": "sk_live_..."
  }
}
```

`keyId` is the identifier you use for every later management call. `key` is the key itself, and this is the only time it's returned. Unkey keeps only a hash, plus an encrypted copy if you asked for a recoverable key. Send it to your user over a secure channel and don't log it.

## Key format

A key looks like `<prefix>_<random>`, or just `<random>` without a prefix. The random part is `byteLength` random bytes in base58. `keys.getKey` and `apis.listKeys` return a `start` field, `<prefix>_<first four characters>`, so you can recognize a key without seeing all of it.

## From the dashboard

The keyspace's **Create key** dialog has the same fields in six steps: general setup, rate limits, credits, expiration, permissions, and metadata. Two things differ from the API. Expiry must be at least two minutes in the future, and the general step has an <Tooltip tip="An optional free-text label stored on a key in the dashboard, such as live or test. Unkey attaches no behavior to it, and it is unrelated to Compute environments.">environment</Tooltip> field, a free-text label of up to 256 characters. The API can't set it and verification doesn't return it, so use `meta` if your backend needs the label.

<Frame>
  <img src="/images/dashboard/api-management--keys-creating-keys--create-key.png" alt="Create key dialog on the General Setup step with Name, Prefix, and External ID fields, and the other steps listed on the left" />
</Frame>

<Visibility for="agents">
  There's no `environment` field on `keys.createKey`, and the v1 names `remaining`, `refill`, and `ratelimit` aren't accepted. Credits live under `credits.remaining` and `credits.refill`, and rate limits under `ratelimits`.
</Visibility>

---

### Api Management Keys Credits And Refill (`docs/api-management/keys/credits-and-refill.mdx`)

---
title: Credits and refill
description: "Give keys a spendable balance that refills on a schedule."
sources:
  - svc/api/openapi/spec/common/KeyCreditsData.yaml
  - svc/api/openapi/spec/common/KeyCreditsRefill.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/KeysVerifyKeyCredits.yaml
  - svc/api/openapi/spec/paths/v2/keys/updateCredits/V2KeysUpdateCreditsRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/updateKey/UpdateKeyCreditsData.yaml
  - svc/api/openapi/spec/paths/v2/keys/updateKey/UpdateKeyCreditsRefill.yaml
  - svc/api/routes/v2_keys_create_key/handler.go
  - svc/api/routes/v2_keys_update_key/handler.go
  - svc/api/routes/v2_keys_verify_key/handler.go
  - internal/services/keys/validation.go
  - internal/services/usagelimiter/limit.go
  - internal/services/usagelimiter/redis.go
  - svc/api/routes/v2_keys_update_credits/handler.go
  - svc/ctrl/worker/cron/keyrefill/handler.go
  - dev/k8s/charts/restate-cronjobs/values.yaml
  - svc/ctrl/internal/db/queries/key_list_for_refill.sql
---

Credits cap how much a key can be used in total. (Rate limits cap how often.) A key starts with a balance, and each verification spends some of it. When the balance can't cover a request, the key stops working until you add more or a scheduled refill restores it. Use credits for "1,000 requests included" plans, trials, and pay-per-use APIs.

## How spending works

A key has credits when `credits.remaining` is a number. If it's `null` or missing, the key is unlimited. Each `keys.verifyKey` call costs 1 unless the request sets `credits.cost`, any integer from 0 to 1,000,000,000,000.

Credits are checked last, so a key that fails any other check (disabled, expired, rate limited, missing permissions) doesn't lose credits. What happens next depends on the balance:

- **Enough credits.** The cost is deducted and the response's `credits` field shows the new balance.
- **Not enough.** Nothing is deducted, the verification fails with `code: USAGE_EXCEEDED`, and `credits` shows the unchanged balance.

The balance never goes below zero. A cost of `0` checks the key without spending anything, so you can check a key or read its metadata for free.

```bash
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "key": "sk_live_...", "credits": { "cost": 10 } }'
```

```json
{
  "meta": { "requestId": "req_..." },
  "data": { "valid": true, "code": "VALID", "keyId": "key_...", "credits": 990 }
}
```

Verifications in every region spend from the same balance, so no spend is lost.

## Add credits to a key

Set `credits.remaining` when creating the key, or later with `keys.updateKey`.

```bash
curl -X POST https://api.unkey.com/v2/keys.createKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "apiId": "api_...", "credits": { "remaining": 1000 } }'
```

<ParamField body="credits.remaining" type="integer | null">
  Starting balance, 0 or more. On `keys.updateKey`, `null` removes the limit entirely and also clears any refill schedule. On `keys.createKey`, `null` isn't allowed, so omit `credits` instead.
</ParamField>

## Refill on a schedule

A refill resets the balance to a fixed amount on a schedule. It replaces the balance rather than adding to it: a key with 50 credits left and a refill of 1000 has 1000 afterward, not 1050. You must set `credits.remaining` in the same request.

<ParamField body="credits.refill.interval" type="string" required>
  `daily` or `monthly`.
</ParamField>

<ParamField body="credits.refill.amount" type="integer" required>
  The balance after each refill, 1 or more.
</ParamField>

<ParamField body="credits.refill.refillDay" type="integer">
  1 to 31. Required when `interval` is `monthly`. Leaving it out fails with HTTP 400 `err:unkey:application:invalid_input`. With `daily`, `keys.updateKey` rejects it with the same error and `keys.createKey` ignores it. If the month is shorter, the refill runs on its last day, so `31` works in February.
</ParamField>

```bash
curl -X POST https://api.unkey.com/v2/keys.createKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "apiId": "api_...",
    "credits": {
      "remaining": 10000,
      "refill": { "interval": "monthly", "amount": 10000, "refillDay": 1 }
    }
  }'
```

Refills run once a day at 00:00 UTC, whatever your customer's time zone. Daily keys refill every day, and monthly keys refill on their `refillDay`. A key that's already at or above its `amount` is left alone.

To stop refilling but keep the current balance, send `credits.refill: null` on `keys.updateKey`. To change the schedule, send the whole `refill` object again.

## Change a balance directly

Use `keys.updateCredits` for top-ups, purchases, and refunds. It changes the balance and leaves the refill schedule alone.

<ParamField body="keyId" type="string" required>
  The key to change.
</ParamField>

<ParamField body="operation" type="string" required>
  `set` replaces the balance with `value`, `increment` adds `value`, and `decrement` subtracts `value` and clamps at zero.
</ParamField>

<ParamField body="value" type="integer | null">
  0 or more. Required for `increment` and `decrement`. With `set`, `null` makes the key unlimited.
</ParamField>

On a key with unlimited credits, `increment` and `decrement` fail with HTTP 400 [`err:unkey:application:invalid_input`](/errors/unkey/application/invalid_input). To give an unlimited key a quota, send `set` first.

```bash
curl -X POST https://api.unkey.com/v2/keys.updateCredits \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "keyId": "key_...", "operation": "increment", "value": 5000 }'
```

The root key needs `api.*.update_key` or `api.<api_id>.update_key` for both `keys.updateKey` and `keys.updateCredits`. See [Root key permissions](/platform/root-keys/permissions).

## Credits vs rate limits

| | Credits | Rate limits |
| --- | --- | --- |
| Controls | Total usage | Usage per time window |
| Resets | Only by refill or `updateCredits` | Automatically as the window slides |
| Failure code | `USAGE_EXCEEDED` | `RATE_LIMITED` |
| Checked | Last, and spent only on success | Before credits |

Use both when you sell a quota and also want burst protection. The rate limit stops a client from burning the quota in a second, and credits enforce the quota. See [Key and identity rate limits](/api-management/ratelimiting/key-and-identity-limits).

---

### Api Management Keys Enable Disable Delete (`docs/api-management/keys/enable-disable-delete.mdx`)

---
title: Disabling and deleting keys
description: "Suspend a key for now or delete it so it never works again."
sources:
  - svc/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/deleteKey/V2KeysDeleteKeyRequestBody.yaml
  - svc/api/routes/v2_keys_update_key/handler.go
  - svc/api/routes/v2_keys_delete_key/handler.go
  - internal/services/keys/get.go
  - internal/services/keys/status.go
  - internal/services/caches/caches.go
  - pkg/cache/cache.go
  - web/apps/dashboard/components/api-keys-table/components/actions/keys-table-action.popover.constants.tsx
---

import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

To stop a key from working, disable it or delete it. Disable when it might be temporary, like a failed payment or an investigation. The key keeps its settings and works again when you enable it. Delete when the key should never work again.

<RootKeyPrereq />

Disabling and enabling use `keys.updateKey` and need `api.*.update_key` or `api.<api_id>.update_key`. Deleting uses `keys.deleteKey` and needs `api.*.delete_key` or `api.<api_id>.delete_key`. See [Root key permissions](/platform/root-keys/permissions).

## Disable or enable a key

<Frame>
  <img src="/images/dashboard/api-management--keys-enable-disable-delete--actions-menu.png" alt="Keys tab with a key's actions menu open, including Disable Key, Rotate key, and Delete key" />
</Frame>

In the dashboard, open the actions menu on the key's row or on its detail page and choose **Disable Key** or **Enable Key**. From the API, set `enabled` on `keys.updateKey`:

<ParamField body="enabled" type="boolean">
  `false` suspends the key and `true` restores it. Omitting the field leaves the state unchanged.
</ParamField>

```bash
curl -X POST https://api.unkey.com/v2/keys.updateKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "keyId": "key_...", "enabled": false }'
```

While a key is disabled, every verification returns `valid: false` with `code: DISABLED`, and it doesn't use up rate limits or credits.

```json
{
  "meta": { "requestId": "req_..." },
  "data": { "valid": false, "code": "DISABLED", "keyId": "key_...", "enabled": false }
}
```

Send `"enabled": true` to restore the key with all its settings as they were. You can also create a key disabled, with `"enabled": false` on `keys.createKey`, for example when access needs approval first.

## Delete a key

In the dashboard, choose **Delete key** from the same actions menu. From the API, call `keys.deleteKey`:

<ParamField body="keyId" type="string" required>
  The key to delete.
</ParamField>

<ParamField body="permanent" type="boolean" default="false">
  `false`, the default, deletes the key but keeps a record of it for your audit trail. `true` erases it completely, so the same key string could be issued again later. Use `true` for regulatory erasure requests. The dashboard always uses the default.
</ParamField>

```bash
curl -X POST https://api.unkey.com/v2/keys.deleteKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "keyId": "key_..." }'
```

After deletion, verification returns `code: NOT_FOUND` and the key disappears from listings. The `key.delete` audit log event says whether it was permanent. **Deletion can't be undone.**

## How quickly it takes effect

A disable, enable, or delete takes about 10 seconds to reach verification, and a few verifications just after that can still see the old state. You can't speed it up. If a key must stop working the instant you say so, your own service has to stop accepting it too.

## Revoke everything a user owns

To find the keys linked to an identity, filter `apis.listKeys` by `externalId`, then delete them one by one.

```typescript revoke-user-keys.ts
let cursor: string | undefined;
do {
  const { data, pagination } = await unkey.apis.listKeys({
    apiId: "api_...",
    externalId: "user_123",
    cursor,
  });
  for (const key of data) {
    await unkey.keys.deleteKey({ keyId: key.keyId });
  }
  cursor = pagination.hasMore ? pagination.cursor : undefined;
} while (cursor);
```

## Disable, delete, or reroll

| Situation | Use |
| --- | --- |
| Billing problem, investigation, pause | Disable, then enable later. |
| User left, key no longer needed | Delete. |
| Key leaked but the user should keep working | [Reroll](/api-management/keys/rerolling-keys): a new key with the same configuration and a grace period for the old one. |
| Erasure request, or you must reuse the exact key string | Delete with `permanent: true`. |

---

### Api Management Keys Expiration (`docs/api-management/keys/expiration.mdx`)

---
title: Key expiration
sidebarTitle: "Expiration"
description: "Give a key an expiry so it stops working at a moment you choose."
sources:
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyRequestBody.yaml
  - svc/api/routes/v2_keys_create_key/handler.go
  - svc/api/routes/v2_keys_update_key/handler.go
  - internal/services/keys/get.go
  - internal/services/keys/status.go
  - internal/services/caches/caches.go
  - pkg/cache/cache.go
  - svc/ctrl/worker/cron/cron.go
  - web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/_components/create-key/create-key.schema.ts
---

Set an expiry to make a key stop working at a time you choose. After that time, every verification returns `code: EXPIRED`. Nothing else about the key changes, so you can extend an expired trial key and it keeps its metadata, credits, and permissions.

## Set an expiry

<ParamField body="expires" type="integer | null">
  Unix timestamp in milliseconds. The maximum is `4102444800000` (1 January 2100). On `keys.updateKey`, omitting the field keeps the current expiry and `null` removes it. A timestamp in the past is accepted and makes the key expired at once.
</ParamField>

```bash
# expire 24 hours from now
EXPIRES=$(( $(date +%s) * 1000 + 86400000 ))

curl -X POST https://api.unkey.com/v2/keys.createKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d "{ \"apiId\": \"api_...\", \"expires\": $EXPIRES }"
```

In the dashboard's **Create key** dialog, the expiry must be at least two minutes in the future.

## What happens at expiry

Expiry is checked against Unkey's clock, not your server's. An expired key uses no rate limits or credits. The response looks like this:

```json
{
  "meta": { "requestId": "req_..." },
  "data": {
    "valid": false,
    "code": "EXPIRED",
    "keyId": "key_...",
    "enabled": true,
    "expires": 1704067200000
  }
}
```

The `expires` value is also returned on valid verifications, so your backend can warn a user that their key is about to lapse.

## Expired keys are kept

Expired keys aren't deleted. They stay in the keyspace, show up in `apis.listKeys` and the dashboard, and keep returning `EXPIRED`. You can extend one and keep its analytics history. To remove expired keys, delete them yourself. See [Disabling and deleting keys](/api-management/keys/enable-disable-delete).

## Extend or remove an expiry

```bash
# extend by 7 days from now
EXPIRES=$(( $(date +%s) * 1000 + 604800000 ))

curl -X POST https://api.unkey.com/v2/keys.updateKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d "{ \"keyId\": \"key_...\", \"expires\": $EXPIRES }"
```

```bash
# make the key permanent
curl -X POST https://api.unkey.com/v2/keys.updateKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "keyId": "key_...", "expires": null }'
```

Extending an expired key takes about 10 seconds to apply, and a few verifications just after that can still return `EXPIRED`. [Verifying keys](/api-management/keys/verifying-keys#how-quickly-changes-take-effect) explains the timing.

## Expiry and rerolling

Rerolling a key copies its expiry to the new key and sets the old key to expire after the grace period you choose. In the dashboard you can't reroll an expired key, and the grace period can't go past the original expiry. The API applies the grace period as requested. See [Rerolling keys](/api-management/keys/rerolling-keys).

## Expiry compared with credits

An expiry ends access at a time. Credits end it after an amount of use. For a trial that ends after seven days or 1,000 requests, whichever comes first, set both. See [Credits and refill](/api-management/keys/credits-and-refill).

---

### Api Management Keys Looking Up Keys (`docs/api-management/keys/looking-up-keys.mdx`)

---
title: Looking up keys
description: "Read a key's configuration by its id or from the key itself."
sources:
  - svc/api/openapi/spec/paths/v2/keys/getKey/V2KeysGetKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/whoami/V2KeysWhoamiRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/whoami/V2KeysWhoamiResponseBody.yaml
  - svc/api/openapi/spec/common/KeyResponseData.yaml
  - svc/api/openapi/spec/common/KeyCreditsData.yaml
  - svc/api/openapi/spec/common/RatelimitResponse.yaml
  - svc/api/openapi/spec/common/Identity.yaml
  - svc/api/routes/v2_keys_get_key/handler.go
  - svc/api/routes/v2_keys_whoami/handler.go
  - svc/ctrl/worker/cron/cron.go
  - svc/ctrl/worker/cron/keylastusedsync/handler.go
  - dev/k8s/charts/restate-cronjobs/values.yaml
---

import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

Look up a key's settings without verifying it or spending anything. Use `keys.getKey` when you have the key's ID, for example on a management screen. Use `keys.whoami` when you have the key itself, for example when a user pastes it into a support form. Both return the same fields, except `keys.whoami` can't return the decrypted key.

<RootKeyPrereq />

Both endpoints need `api.*.read_key` or `api.<api_id>.read_key`. Adding `decrypt: true` to `keys.getKey` additionally needs `api.*.decrypt_key` or `api.<api_id>.decrypt_key`. See [Root key permissions](/platform/root-keys/permissions).

## By identifier

<ParamField body="keyId" type="string" required>
  The `key_...` identifier from `keys.createKey`, a verification response, a listing, or the dashboard. 3 to 255 characters matching `^[a-zA-Z0-9_]+$`.
</ParamField>

<ParamField body="decrypt" type="boolean" default="false">
  Include the plaintext for a key created as recoverable. See [Recoverable keys](/api-management/keys/recoverable-keys).
</ParamField>

```bash
curl -X POST https://api.unkey.com/v2/keys.getKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "keyId": "key_..." }'
```

A key that doesn't exist, is deleted, or belongs to another workspace returns HTTP 404 `err:unkey:data:key_not_found`.

## By plaintext

<ParamField body="key" type="string" required>
  The full key including its prefix, 1 to 512 characters. Any change to it gives a not-found error.
</ParamField>

```bash
curl -X POST https://api.unkey.com/v2/keys.whoami \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "key": "sk_live_..." }'
```

`keys.whoami` doesn't check whether the key is enabled, expired, or within its limits, and it doesn't record a verification. Use `keys.verifyKey` when you need a verdict.

## Response fields

<ResponseField name="keyId" type="string" required>
  The key's identifier.
</ResponseField>

<ResponseField name="start" type="string" required>
  The prefix and first characters of the key, for display in lists.
</ResponseField>

<ResponseField name="enabled" type="boolean" required>
  Whether the key is enabled.
</ResponseField>

<ResponseField name="createdAt" type="integer" required>
  Creation time as Unix milliseconds.
</ResponseField>

<ResponseField name="updatedAt" type="integer">
  Last update as Unix milliseconds, when the key has been updated.
</ResponseField>

<ResponseField name="lastUsedAt" type="integer">
  Last successful verification as Unix milliseconds. It updates about once a minute, so it can lag real usage by up to a minute.
</ResponseField>

<ResponseField name="name" type="string">
  The internal name.
</ResponseField>

<ResponseField name="meta" type="object">
  The key's metadata.
</ResponseField>

<ResponseField name="expires" type="integer">
  Expiry as Unix milliseconds, when set.
</ResponseField>

<ResponseField name="credits" type="object">
  `remaining` (integer or `null` for unlimited) and, when configured, `refill` with `interval`, `amount`, and `refillDay`.
</ResponseField>

<ResponseField name="ratelimits" type="object[]">
  Each key-level rate limit: `id`, `name`, `limit`, `duration`, `autoApply`.
</ResponseField>

<ResponseField name="permissions" type="string[]">
  Permission slugs the key holds directly or through roles.
</ResponseField>

<ResponseField name="roles" type="string[]">
  Role names assigned to the key.
</ResponseField>

<ResponseField name="identity" type="object">
  When linked: `id`, `externalId`, the identity's `meta`, and its `ratelimits`.
</ResponseField>

<ResponseField name="plaintext" type="string">
  The decrypted key. Returned only by `keys.getKey` with `decrypt: true` on a recoverable key. `keys.whoami` never returns it.
</ResponseField>

```json
{
  "meta": { "requestId": "req_..." },
  "data": {
    "keyId": "key_...",
    "start": "sk_live_abc1",
    "enabled": true,
    "name": "Acme production",
    "createdAt": 1704067200000,
    "lastUsedAt": 1704153600000,
    "expires": 1767225600000,
    "meta": { "plan": "pro" },
    "credits": {
      "remaining": 9420,
      "refill": { "interval": "monthly", "amount": 10000, "refillDay": 1 }
    },
    "ratelimits": [
      { "id": "rl_...", "name": "requests", "limit": 100, "duration": 60000, "autoApply": true }
    ],
    "permissions": ["documents.read", "documents.write"],
    "roles": ["editor"],
    "identity": { "id": "id_...", "externalId": "org_acme", "meta": {} }
  }
}
```

To page through many keys at once, use `apis.listKeys`, which returns the same objects. See [Listing keys](/api-management/keyspaces/listing-keys).

---

### Api Management Keys Metadata And Tags (`docs/api-management/keys/metadata-and-tags.mdx`)

---
title: Metadata and tags
description: "Attach data to a key and to each verification of it."
sources:
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
  - svc/api/openapi/spec/common/Identity.yaml
  - svc/api/routes/v2_keys_create_key/handler.go
  - svc/api/routes/v2_keys_update_key/handler.go
  - svc/api/routes/v2_keys_verify_key/handler.go
  - internal/services/keys/verifier.go
  - pkg/clickhouse/schema/001_key_verifications_raw_v2.sql
  - web/internal/db/src/schema/keys.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/_components/create-key/create-key.schema.ts
---

Metadata is data you store on a key, like a plan tier. It comes back on every verification, so your backend doesn't need a database lookup. Tags are labels you send with each verification, like the endpoint called. They go only to analytics, so you can break usage down later.

## Metadata

<ParamField body="meta" type="object | null">
  A JSON object with at most 100 top-level properties. Nesting is allowed. Set it on `keys.createKey` or replace it on `keys.updateKey`, where leaving it out keeps the current value and `null` removes it. Every `keys.verifyKey` response returns it as `data.meta`.
</ParamField>

```bash
curl -X POST https://api.unkey.com/v2/keys.createKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "apiId": "api_...",
    "meta": { "plan": "pro", "features": ["exports", "webhooks"], "billingEmail": "ops@example.com" }
  }'
```

Keep metadata to what your request handler needs right away, like a plan tier, feature flags, or a tenant ID. Anything large slows every verification, and any service that calls `keys.verifyKey` can see it, so don't store secrets. To change one property, send the whole object again.

Metadata on an identity comes back separately as `data.identity.meta`. Put facts shared by all of a user's keys there, and key-specific facts on the key.

<Visibility for="agents">
  The dashboard's **Create key** dialog also has an <Tooltip tip="An optional free-text label stored on a key in the dashboard, such as live or test. Unkey attaches no behavior to it, and it is unrelated to Compute environments.">environment</Tooltip> field that sets a label on the key. That label isn't part of `keys.createKey`, `keys.updateKey`, or the verification response. Store such labels in `meta` when your backend must read them.
</Visibility>

## Tags

<ParamField body="tags" type="string[]">
  On `keys.verifyKey`. Up to 20 strings of 1 to 512 characters each. They're recorded with the verification and don't change the result.
</ParamField>

```bash
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "key": "sk_live_...",
    "tags": ["endpoint=/documents", "method=GET", "region=eu-west-1"]
  }'
```

Tags land in the `tags` column of the verifications table in analytics, so you can break usage down by endpoint, client version, or anything else. A `key=value` format makes them easy to filter. Don't put anything sensitive in a tag, because tags show up in analytics and the dashboard's verification logs. The [key verifications table](/api-management/analytics/tables/key-verifications) shows how to query them.

## Which one to use

| Need | Use |
| --- | --- |
| Know the caller's plan or flags while handling the request | `meta` on the key, or `meta` on the identity if it is shared by all of a user's keys |
| Break usage reports down by endpoint, version, or region | `tags` on each verification |
| Tell live keys from test keys in your code | `meta`, since the dashboard environment label is not returned by verification |

---

### Api Management Keys Migrating Keys (`docs/api-management/keys/migrating-keys.mdx`)

---
title: Migrating existing keys
sidebarTitle: "Migrating keys"
description: "Bring existing keys from another system into Unkey without rotating them."
sources:
  - svc/api/openapi/spec/paths/v2/keys/migrateKeys/V2KeysMigrateKeysRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/migrateKeys/V2KeysMigrateKeyData.yaml
  - svc/api/openapi/spec/paths/v2/keys/migrateKeys/V2KeysMigrateKeysResponseData.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
  - svc/api/routes/v2_keys_migrate_keys/handler.go
  - svc/api/routes/v2_keys_verify_key/handler.go
  - internal/services/keys/get_migrated.go
  - internal/services/keys/db/models_generated.go
  - pkg/hash/sha256.go
  - web/internal/db/src/schema/keys.ts
---

import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

Move homegrown API keys to Unkey by sending the hash of each key, never the key itself. Users keep the keys they already have. After the import, your backend calls `keys.verifyKey` instead of looking keys up in your own table.

After the move, you no longer store plaintext keys or write the hash comparison. If you switch those checks over too, you also stop running your own rate-limit counters and monthly usage counters. You still run your API, unless you deploy it on Compute, and you still return 401 and 429 to your users. Unkey stores a SHA-256 hash of the full key, base64 encoded, and compares that on verification. See [How Unkey stores API keys](/platform/security/overview#how-unkey-stores-api-keys).

Agree the hash format with Unkey before the import. The two formats are below.

## Get a migration ID

Email [support@unkey.com](mailto:support@unkey.com) with your workspace ID, the system the keys come from, and how they're hashed. Support sends a `migrationId`. Two hash formats work today.

| Algorithm | What you send as `hash` | Verification |
| --- | --- | --- |
| `sha256` | SHA-256 of the full key string, **base64** encoded. This is Unkey's native format. | Works immediately with a plain `keys.verifyKey`; no `migrationId` needed at verify time. |
| `github.com/seamapi/prefixed-api-key` | SHA-256 of the key's long token only, **hex** encoded, as that library stores it. Keys look like `prefix_shortToken_longToken`. | Send `migrationId` on `keys.verifyKey` until each key has been verified once. After that the key verifies without it. |

Keys hashed any other way, such as bcrypt or a salted hash, can't be imported from the hash alone. Tell support, and the migration needs another plan.

### Hash the keys yourself

For `sha256`, this is the same function Unkey uses, so a key hashed this way works exactly like one Unkey created.

```typescript hash.ts
import { createHash } from "node:crypto";

function hashKey(plaintext: string): string {
  return createHash("sha256").update(plaintext).digest("base64");
}

hashKey("sk_live_abc"); // "5Q7m..." base64, not hex
```

Never include plaintext keys in the export or the migration request.

<RootKeyPrereq />

`keys.migrateKeys` needs `api.*.create_key` or `api.<api_id>.create_key`. Verifying afterwards needs the usual `api.*.verify_key`. See [Root key permissions](/platform/root-keys/permissions).

## Import the hashes

<ParamField body="migrationId" type="string" required>
  The identifier from support, 3 to 255 characters. An unknown ID returns HTTP 404 `err:unkey:data:migration_not_found`.
</ParamField>

<ParamField body="apiId" type="string" required>
  The keyspace to import into.
</ParamField>

<ParamField body="keys" type="object[]" required>
  At least one entry. Each has a required `hash` (3 or more characters, in your migration's format) and the same optional fields as `keys.createKey`: `name`, `externalId`, `meta`, `roles` (up to 100), `permissions` (up to 1000), `expires`, `enabled` (default `true`), `credits`, and `ratelimits` (up to 50). The rules match [Creating keys](/api-management/keys/creating-keys).
</ParamField>

```bash
curl -X POST https://api.unkey.com/v2/keys.migrateKeys \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "migrationId": "<from support>",
    "apiId": "api_...",
    "keys": [
      {
        "hash": "xfV3...base64...=",
        "name": "Acme production",
        "externalId": "org_acme",
        "meta": { "migratedFrom": "legacy" },
        "ratelimits": [
          { "name": "requests", "limit": 1000, "duration": 60000, "autoApply": true }
        ]
      }
    ]
  }'
```

## Response

The response is HTTP 200 even when some hashes fail. A hash that already exists anywhere in Unkey, not just in your workspace, is skipped and listed under `failed`. Every other key is created and returned with its new `keyId`. Store it next to your own record of the key.

```json
{
  "meta": { "requestId": "req_..." },
  "data": {
    "migrated": [
      { "hash": "xfV3...base64...=", "keyId": "key_..." }
    ],
    "failed": [
      "duplicate...hash...="
    ]
  }
}
```

Send large migrations in batches and reconcile `migrated` against your export after each one.

## Switch verification over

Point your backend at `keys.verifyKey`. For the `sha256` algorithm nothing else changes. For the prefixed-api-key algorithm, include the `migrationId` in every verification during the roll-out:

```bash
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "key": "prefix_short_long", "migrationId": "<from support>" }'
```

After a key's first verification with `migrationId`, it works with or without the field. A key that hasn't been verified yet still needs it and returns `NOT_FOUND` without it, so keep sending `migrationId` until the fallback below goes quiet.

## Run both systems during the switch

Verify against Unkey first. If a key comes back `NOT_FOUND`, check your old system and log it. Remove the fallback when the log goes quiet. Create new keys with `keys.createKey` so new users are never in the old system.

---

### Api Management Keys Recoverable Keys (`docs/api-management/keys/recoverable-keys.mdx`)

---
title: Recoverable keys
description: "Store an encrypted copy of a key so you can show it again later."
sources:
  - svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/getKey/V2KeysGetKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/apis/listKeys/V2ApisListKeysRequestBody.yaml
  - svc/api/openapi/spec/common/KeyResponseData.yaml
  - svc/api/routes/v2_keys_create_key/handler.go
  - svc/api/routes/v2_keys_get_key/handler.go
  - svc/api/routes/v2_keys_reroll_key/handler.go
  - web/internal/db/src/schema/keyAuth.ts
  - web/internal/db/src/schema/keys.ts
  - web/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts
---

import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

A recoverable key is one you can show again after it's created. By default Unkey keeps only a hash of each key, so once `keys.createKey` returns it, nobody (including Unkey) can read it again. That's the safest setup and right for most apps: a user who loses a key gets a new one.

Use recoverable keys when showing a key again is worth the extra risk, such as an API playground that needs a working key, or a settings page where users expect to reveal their key. We store an encrypted copy and decrypt it only when you ask. [Key storage](/platform/security/key-storage) explains how that copy is protected.

## Opt the keyspace in

Recoverable keys need the keyspace's **Store encrypted keys** setting. It's off for new keyspaces and there's no dashboard toggle, so email support with the keyspace's API ID and we'll turn it on. Keys created before that can't be recovered.

<RootKeyPrereq />

Creating a recoverable key needs `api.*.encrypt_key` or `api.<api_id>.encrypt_key` in addition to the create permission. Reading one back needs `api.*.decrypt_key` or `api.<api_id>.decrypt_key` in addition to the read permission. Grant these only to root keys that need them. See [Root key permissions](/platform/root-keys/permissions).

A missing encrypt permission doesn't look like a permission problem. `keys.createKey` returns HTTP 404 [`err:unkey:data:api_not_found`](/errors/unkey/data/api_not_found), "The specified API was not found." If you see this for an API ID you know is correct, check the root key's `encrypt_key` permission first.

## Create a recoverable key

<ParamField body="recoverable" type="boolean" default="false">
  On `keys.createKey`. When `true`, an encrypted copy of the key is stored. Fails with HTTP 412 [`err:unkey:application:precondition_failed`](/errors/unkey/application/precondition_failed) when the keyspace doesn't store encrypted keys ("This API does not support key encryption."), and with HTTP 404 `err:unkey:data:api_not_found` when the root key lacks `encrypt_key`.
</ParamField>

```bash
curl -X POST https://api.unkey.com/v2/keys.createKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "apiId": "api_...", "recoverable": true }'
```

Rerolling a recoverable key produces a recoverable key, so the reroll needs the encrypt permission too.

## Read the plaintext back

Both `keys.getKey` and `apis.listKeys` accept `decrypt`.

<ParamField body="decrypt" type="boolean" default="false">
  When `true`, every returned key that's recoverable has a `plaintext` field. Other keys come back without it, and the request doesn't fail.
</ParamField>

```bash
curl -X POST https://api.unkey.com/v2/keys.getKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "keyId": "key_...", "decrypt": true }'
```

```json
{
  "meta": { "requestId": "req_..." },
  "data": {
    "keyId": "key_...",
    "start": "sk_live_abc1",
    "enabled": true,
    "createdAt": 1704067200000,
    "plaintext": "sk_live_..."
  }
}
```

`decrypt: true` fails with HTTP 412 when the key's keyspace doesn't store encrypted keys, and with a permission error when the root key lacks `decrypt_key`. Treat any response with `plaintext` like the original create response: send it over a secure channel, and never log or cache it.

## Recoverable keys and rotation

If a key may have leaked, reroll it. Being able to read it again doesn't make it safe. See [Rerolling keys](/api-management/keys/rerolling-keys).

---

### Api Management Keys Rerolling Keys (`docs/api-management/keys/rerolling-keys.mdx`)

---
title: Rerolling keys
description: "Replace a key's secret while keeping its configuration and a grace period."
sources:
  - svc/api/openapi/spec/paths/v2/keys/rerollKey/V2KeysRerollKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/rerollKey/V2KeysRerollKeyResponseData.yaml
  - svc/api/routes/v2_keys_reroll_key/handler.go
  - web/apps/dashboard/components/api-keys-table/components/actions/components/rotate-key/rotate-key.constants.ts
  - web/apps/dashboard/lib/trpc/routers/key/reroll/index.ts
  - web/apps/dashboard/components/api-keys-table/components/actions/keys-table-action.popover.constants.tsx
---

import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

Rerolling gives a key a new secret and keeps its settings. Unkey creates a new key with the same settings, and the old key keeps working for a grace period you choose. Use it for scheduled rotation, after a suspected leak, or when a user asks for a fresh key.

<RootKeyPrereq />

The root key needs `api.*.create_key` or `api.<api_id>.create_key`. When the original key is recoverable, it also needs `api.*.encrypt_key` or `api.<api_id>.encrypt_key`. See [Root key permissions](/platform/root-keys/permissions).

## Request

<ParamField body="keyId" type="string" required>
  The identifier of the key to reroll (`key_...`), not the key string itself.
</ParamField>

<ParamField body="expiration" type="integer | null" required>
  Milliseconds from now until the old key stops working. The maximum is `4102444800000`. The time is rounded up to the next whole minute, so `expiration: 1` can leave the old key working for up to a minute. Only `0` revokes it immediately. Set it to `null` to keep the old key working; it keeps its current expiry, if it has one.
</ParamField>

```bash
curl -X POST https://api.unkey.com/v2/keys.rerollKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "keyId": "key_...", "expiration": 86400000 }'
```

## Response

```json
{
  "meta": { "requestId": "req_..." },
  "data": {
    "keyId": "key_...",
    "key": "sk_live_..."
  }
}
```

`key` is the new key, and this is the only time it's returned. `keyId` is the new key's ID. The old key keeps its own `keyId` until it expires.

## What the new key copies

The new key copies the old key's enabled state, name, metadata, identity, expiry, credits and refill schedule, roles, permissions, and rate limits. If the old key was recoverable, so is the new one.

The new key gets a new `keyId` and secret. It keeps the old key's prefix, or uses the keyspace's default prefix if it had none. Its length is the keyspace's current default, or 16 bytes if none is set, so a key created with a custom `byteLength` can change length.

Analytics by identity carry on across the reroll. Analytics by `keyId` start fresh for the new key.

## What happens to the original

The old key's expiry becomes now plus `expiration`, rounded up to the next minute. With `null`, the old key's expiry does not change. With `0`, its next verification returns `code: EXPIRED`. The API applies the grace period even if the old key was due to expire sooner, so a reroll can extend its life. Check the old key's `expires` first if that matters.

The event is recorded in the audit log as `key.reroll`.

## From the dashboard

Choose **Rotate key** from the key's actions menu. Pick a grace period: revoke immediately, 15 minutes, 1 hour, 6 hours, 24 hours (the default), or **Do not expire original key**. That last option keeps the old key working with its current expiry. The dashboard is stricter than the API: you can't rotate an expired key, and the grace period can't go past the key's original expiry. The new key is shown once.

<Frame>
  <img src="/images/dashboard/api-management--keys-rerolling-keys--rotate-key.png" alt="Rotate key dialog with the grace period dropdown open, from Revoke immediately to 24 hours" />
</Frame>

## Reroll or reissue

Reroll when the user should keep working without a break. `keys.rerollKey` can't change settings, so to change them too, update the new key with `keys.updateKey` afterward, or create a new key and delete the old one.

---

### Api Management Keys Updating Keys (`docs/api-management/keys/updating-keys.mdx`)

---
title: Updating keys
description: "Change a key in place, and clear a field rather than recreating the key."
sources:
  - svc/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/updateKey/UpdateKeyCreditsData.yaml
  - svc/api/openapi/spec/paths/v2/keys/updateKey/UpdateKeyCreditsRefill.yaml
  - svc/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyResponseBody.yaml
  - svc/api/openapi/spec/common/RatelimitRequest.yaml
  - svc/api/openapi/spec/common/KeyCreditsData.yaml
  - svc/api/routes/v2_keys_update_key/handler.go
  - svc/api/routes/v2_keys_update_key/three_state_test.go
  - svc/api/routes/v2_keys_update_key/400_test.go
  - svc/api/routes/v2_keys_create_key/handler.go
  - pkg/auditlog/events.go
---

import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

Use `keys.updateKey` to change what a key can do without issuing a new one. The key string stays the same, so your users keep working. Only `keyId` is required.

For every other field:

- Leave it out to keep the current value.
- Send a value to replace it.
- Send `null` to clear it, for example to remove an expiry, a refill schedule, or an identity link.

<RootKeyPrereq />

The root key needs `api.*.update_key` or `api.<api_id>.update_key`. Without it you get HTTP 403 [`err:unkey:authorization:insufficient_permissions`](/errors/unkey/authorization/insufficient_permissions). A `keyId` that doesn't exist or belongs to another workspace returns HTTP 404 [`err:unkey:data:key_not_found`](/errors/unkey/data/key_not_found). See [Root key permissions](/platform/root-keys/permissions).

## Request fields

<ParamField body="keyId" type="string" required>
  The key's ID (`key_...`), not the key string. 3 to 255 characters matching `^[a-zA-Z0-9_]+$`.
</ParamField>

<ParamField body="name" type="string | null">
  1 to 255 characters. `null` removes the name.
</ParamField>

<ParamField body="externalId" type="string | null">
  1 to 255 characters matching `^[a-zA-Z0-9_.-]+$`. Moves the key to the identity with that `externalId`, creating the identity in the keyspace's project if it doesn't exist yet. An existing identity in a different project is rejected with HTTP 404 [`err:unkey:data:identity_not_found`](/errors/unkey/data/identity_not_found). `null` unlinks the key from its identity. See [Identities](/api-management/identities/overview).
</ParamField>

<ParamField body="meta" type="object | null">
  JSON with at most 100 top-level properties. It replaces the old metadata rather than merging. `null` removes it. See [Metadata and tags](/api-management/keys/metadata-and-tags).
</ParamField>

<ParamField body="expires" type="integer | null">
  Unix timestamp in milliseconds, at most `4102444800000` (1 January 2100). `null` makes the key permanent. A timestamp in the past is accepted and expires the key at once. See [Key expiration](/api-management/keys/expiration).
</ParamField>

<ParamField body="credits" type="object | null">
  `credits.remaining` (integer or `null`, 0 or more) and `credits.refill` (object or `null`). Unlike `keys.createKey`, neither is required, so you can change a schedule without resending the balance. See [Credits and refill on update](#credits-and-refill-on-update). See [Credits and refill](/api-management/keys/credits-and-refill).
</ParamField>

<ParamField body="ratelimits" type="object[] | null">
  Up to 50 limits, each with `name` (3 to 128 characters), `limit` (1 or more), `duration` in milliseconds (1000 or more), and `autoApply`. The list replaces the key's limits rather than adding to them. `null` or `[]` removes them all. See [Key and identity rate limits](/api-management/ratelimiting/key-and-identity-limits).
</ParamField>

<ParamField body="enabled" type="boolean">
  `false` suspends the key and `true` restores it. Not nullable. See [Disabling and deleting keys](/api-management/keys/enable-disable-delete).
</ParamField>

<ParamField body="roles" type="string[]">
  Up to 100 role names, each 1 to 128 characters. The list replaces the key's roles. Not nullable, so send `[]` to detach them all.
</ParamField>

<ParamField body="permissions" type="string[]">
  Up to 1000 permission slugs, each 1 to 128 characters matching `^[a-zA-Z0-9_:\-\.\*]+$`. The list replaces the key's direct permissions. Not nullable, so send `[]` to detach them all.
</ParamField>

## Credits and refill on update

The balance is `credits.remaining` and the schedule is `credits.refill`. Clearing one sometimes clears the other:

| Sent | Balance | Refill schedule |
| --- | --- | --- |
| `credits` omitted | unchanged | unchanged |
| `credits: null` | cleared, key becomes unlimited | cleared |
| `credits.remaining: null` | cleared, key becomes unlimited | cleared |
| `credits.remaining: 500` | set to 500 | unchanged |
| `credits.refill: null` | unchanged | cleared |
| `credits.refill: { ... }` | unchanged | replaced |

Removing the balance removes the schedule too. Removing the schedule keeps the balance, so you can stop a subscription from renewing without taking away credits the customer already paid for.

A `refill` object has `interval` (`daily` or `monthly`) and `amount` (1 or more). `refillDay` (1 to 31) is for `monthly` only. Sending it with `daily`, or leaving it out with `monthly`, fails with HTTP 400 [`err:unkey:application:invalid_input`](/errors/unkey/application/invalid_input). (`keys.createKey` ignores `refillDay` with `daily`, so a body that works for create can fail here.)

To change only the balance, use `keys.updateCredits`.

## Lists replace, they don't merge

`ratelimits`, `roles`, and `permissions` are each the full list you want the key to end up with. Send every entry you want to keep, not just the new ones.

- **Rate limits** are matched by `name`. A limit you send keeps its ID and gets the new values. A limit you leave out is deleted.
- **Roles** must already exist. The first one that doesn't fails the request with HTTP 404 [`err:unkey:data:role_not_found`](/errors/unkey/data/role_not_found).
- **Permissions** that don't exist yet are created for you. This list only replaces the key's direct permissions, not what its roles grant.

To add or remove one entry without resending the list, use `keys.addPermissions`, `keys.removePermissions`, `keys.addRoles`, or `keys.removeRoles` instead. [Managing key permissions](/api-management/authorization/managing-key-permissions) compares them.

## Example

Move a key to the paid plan, extend it, and stop it expiring:

```bash
curl -X POST https://api.unkey.com/v2/keys.updateKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "keyId": "key_...",
    "meta": { "plan": "pro" },
    "expires": null,
    "credits": {
      "remaining": 10000,
      "refill": { "interval": "monthly", "amount": 10000, "refillDay": 1 }
    },
    "ratelimits": [
      { "name": "requests", "limit": 1000, "duration": 60000, "autoApply": true }
    ]
  }'
```

## Response

```json
{
  "meta": { "requestId": "req_..." },
  "data": {}
}
```

The response has no key data. Use `keys.getKey` to read the result.

## When the change takes effect

An update applies in full or not at all, so an error leaves the key as it was.

Changes take about 10 seconds to reach verification, and a few verifications just after that can still see the old settings. A new credit balance applies right away. [Verifying keys](/api-management/keys/verifying-keys#how-quickly-changes-take-effect) has the details.

Each update writes a `key.update` [audit event](/platform/workspace/audit-logs/event-types), plus a `permission.create` event for each permission it created.

## What you can't change

You can't change `apiId`, `prefix`, `byteLength`, or `recoverable` after a key is created. To replace the key string while keeping the same configuration, see [Rerolling keys](/api-management/keys/rerolling-keys). To start over, [create a new key](/api-management/keys/creating-keys) and delete the old one.

<Visibility for="agents">
  `credits.remaining` is optional on `keys.updateKey` although it's required inside `credits` on `keys.createKey`. Omitting a field isn't the same as sending `null`: omitting keeps the stored value and `null` clears it. `enabled`, `roles`, and `permissions` don't accept `null`.
</Visibility>

---

### Api Management Keys Verifying Keys (`docs/api-management/keys/verifying-keys.mdx`)

---
title: Verify API keys with Unkey
sidebarTitle: "Verifying keys"
description: "Unkey checks an API key for validity, rate limits, and credits, and Compute runs that check before your API."
sources:
  - svc/api/routes/v2_keys_verify_key/handler.go
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/KeysVerifyKeyCredits.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/KeysVerifyKeyRatelimit.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/index.yaml
  - internal/services/keys/get.go
  - internal/services/keys/verifier.go
  - internal/services/keys/validation.go
  - internal/services/keys/status.go
  - internal/services/caches/caches.go
  - pkg/cache/cache.go
  - internal/services/ratelimit/ratelimit.go
  - pkg/rbac/parser.go
  - svc/frontline/internal/policies/keyauth/executor.go
---

import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

Unkey checks the key your user sent by calling `keys.verifyKey` from your own server. One call runs expiry, an IP allow list when the keyspace has one, permissions, rate limits, and credits, then returns `data.valid` and a `code`. The HTTP status stays 200 when the key fails, and your server returns 401 or 429 to the caller. On Compute, the API key authentication policy runs the same checks at the gateway and returns 401, 403 or 429 itself.

## What the check tells you

Read `data.code`. These are the outcomes you usually branch on. The full list, including the status to return, is under [Code values](#code-values).

| You need to know | What Unkey returns |
| --- | --- |
| Is the key allowed? | `valid: true` and `code: VALID` |
| Is it missing, disabled, or expired? | `NOT_FOUND`, `DISABLED`, or `EXPIRED` |
| Is the caller blocked? | `FORBIDDEN` when the client IP is off the keyspace allow list, or the workspace is disabled |
| Does it have permission? | `INSUFFICIENT_PERMISSIONS` when the `permissions` query fails |
| Is it over a rate limit? | `RATE_LIMITED`. That request doesn't spend credits |
| Has it used its quota? | `USAGE_EXCEEDED` when credits can't cover the cost |
| Should the gateway check the key? | The [API key authentication policy](/compute/gateway/api-key-auth) runs the same checks and returns 401, 403, or 429 itself |

Verify an API key for your public API by calling `keys.verifyKey` with the key your user sent. This is the API Management endpoint. Use it when your own server, not the Compute gateway, decides whether the request is allowed.

Send the key exactly as your user sent it. Unkey runs the checks set on the key, plus any you add to the request, and returns one answer. The response is HTTP 200 for every outcome, including an invalid key, a rate limit, and a used-up credit balance. Read `data.valid` and `data.code`, not the status code. Your server then returns 401 or 429 to the caller. See [Rate limiting](/api-management/ratelimiting/overview#what-a-limited-request-looks-like) for `Retry-After`.

<RootKeyPrereq />

The root key needs `api.*.verify_key` or `api.<api_id>.verify_key` for the key's keyspace. Without it you get a 200 with `code: NOT_FOUND`, not a permission error. A missing or invalid root key gets HTTP 401. See [Root key permissions](/platform/root-keys/permissions).

## Request

<ParamField body="key" type="string" required>
  The key your user sent, 1 to 512 characters, including its prefix. Any change to the string gives `NOT_FOUND`. You don't pass an API ID because the key already belongs to one keyspace.
</ParamField>

<ParamField body="keyspaces" type="string[]">
  1 to 5 keyspace IDs (`ks_...`), each up to 100 characters, matched exactly. The key must belong to one of them or the result is `NOT_FOUND`. Use it when one root key verifies several keyspaces but an endpoint should only accept keys from some of them. An empty array is HTTP 400.
</ParamField>

<ParamField body="permissions" type="string">
  A permission query, 1 to 1000 characters, such as `documents.read AND (billing.read OR billing.write)`. `AND` binds tighter than `OR`, and parentheses group. Fails the verification with `INSUFFICIENT_PERMISSIONS` when the key's direct and role-derived permissions don't satisfy it. A malformed query is HTTP 400 `err:user:bad_request:permissions_query_syntax_error`. Asterisks inside slugs are matched literally. See [Permission queries](/api-management/authorization/permission-queries).
</ParamField>

<ParamField body="credits" type="object">
  `credits.cost` (integer, 0 to 1,000,000,000,000) is how many credits this verification spends. Omitting the object spends 1 on keys that have credits configured and nothing on unlimited keys. `cost: 0` checks the key without spending. See [Credits and refill](/api-management/keys/credits-and-refill).
</ParamField>

<ParamField body="ratelimits" type="object[]">
  Rate limits to check on this call. Each entry names a limit configured on the key or its identity (`name`, 3 to 255 characters) and may pass `cost` (default 1). Naming a limit that doesn't exist on the key or its identity fails the whole request with HTTP 412 `err:unkey:application:precondition_failed`. An entry that also carries both `limit` and `duration` defines an inline, key-scoped limit for this call instead. Limits with `autoApply: true` are checked whether or not you list them.

  An inline `limit` must be 1 or more and `duration` at least 1000 milliseconds. Values below that aren't rejected with an error. Instead the verification skips rate limiting entirely, including the auto-applied limits, and can still return `VALID`. Check these two values in your own code before you send them.
</ParamField>

<ParamField body="tags" type="string[]">
  Up to 20 strings of up to 512 characters, recorded with the verification for analytics only. They never affect the verdict. See [Metadata and tags](/api-management/keys/metadata-and-tags).
</ParamField>

<ParamField body="migrationId" type="string">
  Up to 256 characters. Lets Unkey match a key imported from another provider's hash format on its first use and convert it to the native hash. See [Migrating existing keys](/api-management/keys/migrating-keys).
</ParamField>

```bash
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "key": "sk_live_...",
    "permissions": "documents.read",
    "credits": { "cost": 2 },
    "ratelimits": [{ "name": "requests" }],
    "tags": ["endpoint=/documents", "method=GET"]
  }'
```

## Order of checks

Checks run in this order and stop at the first failure. The order decides which `code` you get, and whether a rate limit or credits get used up.

1. **Existence.** The key must exist, its workspace and keyspace must not be deleted, and your root key must be allowed to verify keys in that keyspace. Anything else is `NOT_FOUND`.
2. **Keyspace restriction.** If the request lists `keyspaces` and the key's keyspace isn't one of them, the result is `NOT_FOUND` with no key details.
3. **Workspace.** A disabled workspace produces `FORBIDDEN`.
4. **Enabled.** A key with `enabled: false` produces `DISABLED`.
5. **Expiration.** A key whose `expires` is in the past produces `EXPIRED`.
6. **IP allow list.** If the keyspace has one, the request's client IP must be on it, or the result is `FORBIDDEN`.
7. **Permissions.** If the request carries a `permissions` query, the key must satisfy it, or the result is `INSUFFICIENT_PERMISSIONS`.
8. **Rate limits.** Auto-applied limits and limits named in the request are checked together. If any is exceeded, the result is `RATE_LIMITED`, and the other limits aren't used up.
9. **Credits.** Only if everything above passed, the cost is deducted. Not enough credits gives `USAGE_EXCEEDED` and nothing is deducted.

So a rate-limited request doesn't spend credits, but a request that runs out of credits still counts against its rate limits.

## Response

<ResponseField name="valid" type="boolean" required>
  `true` only when every check passed. Read this first.
</ResponseField>

<ResponseField name="code" type="string" required>
  One of `VALID`, `NOT_FOUND`, `FORBIDDEN`, `INSUFFICIENT_PERMISSIONS`, `USAGE_EXCEEDED`, `RATE_LIMITED`, `DISABLED`, `EXPIRED`. Code values explains each one.
</ResponseField>

<ResponseField name="keyId" type="string">
  The key's identifier. Present for every outcome except `NOT_FOUND`.
</ResponseField>

<ResponseField name="keyspaceId" type="string">
  The keyspace the key belongs to (`ks_...`). Present for every outcome except `NOT_FOUND`.
</ResponseField>

<ResponseField name="name" type="string">
  The key's internal name, when set.
</ResponseField>

<ResponseField name="enabled" type="boolean">
  Whether the key is enabled.
</ResponseField>

<ResponseField name="expires" type="integer">
  Expiry as Unix milliseconds, when set.
</ResponseField>

<ResponseField name="credits" type="integer">
  Credits remaining after this call. Omitted for unlimited keys. After `USAGE_EXCEEDED` it's the unchanged balance.
</ResponseField>

<ResponseField name="meta" type="object">
  The key's metadata, when set.
</ResponseField>

<ResponseField name="permissions" type="string[]">
  Every permission slug the key holds, directly or through roles.
</ResponseField>

<ResponseField name="roles" type="string[]">
  Every role name assigned to the key.
</ResponseField>

<ResponseField name="identity" type="object">
  When the key is linked to an identity: `id`, `externalId`, the identity's `meta`, and its `ratelimits` (each with `id`, `name`, `limit`, `duration`, `autoApply`).
</ResponseField>

<ResponseField name="ratelimits" type="object[]">
  One entry per rate limit that was checked: `id` (empty for inline limits), `name`, `limit`, `duration`, `remaining`, `reset` (Unix milliseconds), `exceeded`, and `autoApply`.
</ResponseField>

### Code values

| `code` | Meaning | Typical HTTP status to return to your user |
| --- | --- | --- |
| `VALID` | All checks passed. | proceed |
| `NOT_FOUND` | No such key; the key belongs to a deleted keyspace or a workspace your root key cannot verify for; or the request's `keyspaces` list excluded it. | 401 |
| `DISABLED` | The key has `enabled: false`. | 401 or 403 |
| `EXPIRED` | The key's `expires` has passed. | 401 |
| `FORBIDDEN` | Client IP not on the keyspace's allow list, or the workspace is disabled. | 403 |
| `INSUFFICIENT_PERMISSIONS` | The `permissions` query was not satisfied. | 403 |
| `RATE_LIMITED` | At least one checked rate limit is exceeded. | 429 |
| `USAGE_EXCEEDED` | Not enough credits for the requested cost. | 402 or 429 |

## Why some failures say NOT_FOUND

Unkey never tells a caller that a key exists unless they're allowed to verify it. So you get a 200 with `code: NOT_FOUND` when the key belongs to another workspace, its keyspace was deleted, your root key has no verify permission for its keyspace, or your `keyspaces` list leaves it out. The response looks the same in every case. If you get `NOT_FOUND` for a key you know exists, check your root key's permissions first.

## How quickly changes take effect

A change to a key (disable, delete, new expiry, new permissions or roles) takes about 10 seconds to reach verification, and a few verifications just after that can still see the old state. Plan incident runbooks around a little more than 10 seconds, not an instant cutoff.

Credit balances change instantly. Rate limit counts sync across regions within moments, so `remaining` is approximate. See [How rate limiting works](/api-management/ratelimiting/how-it-works).

## What gets recorded

Every verification, whatever its outcome, is recorded in analytics with its outcome, key, identity, tags, credits spent, and latency. Verifications made with a root key also produce a `key.verify` audit log event. See [Analytics](/api-management/analytics/overview) and [Audit logs](/platform/workspace/audit-logs/overview).

---

### Api Management Keyspaces Listing Keys (`docs/api-management/keyspaces/listing-keys.mdx`)

---
title: Listing keys
description: "Page through the keys in a keyspace or find the ones a user owns."
sources:
  - svc/api/openapi/spec/paths/v2/apis/listKeys/V2ApisListKeysRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/apis/listKeys/V2ApisListKeysResponseBody.yaml
  - svc/api/openapi/spec/common/KeyResponseData.yaml
  - svc/api/openapi/spec/common/Pagination.yaml
  - svc/api/routes/v2_apis_list_keys/handler.go
  - web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/keys/[keyAuthId]/_components/filters.schema.ts
---

import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

Use `apis.listKeys` to list the keys in a keyspace, up to 100 per page. It's handy for a "your API keys" screen, for finding every key a user owns, or for auditing a keyspace. It only returns the full key for recoverable keys, and only when you ask.

By default, keys are read from a replica, which may not reflect recent changes. Set `revalidateKeysCache: true` to read keys and their identities from the primary database instead, including when you filter by `externalId`. Primary reads can have higher latency, depending on your region.

<RootKeyPrereq />

The root key needs two permissions: `api.*.read_key` or `api.<api_id>.read_key`, **and** `api.*.read_api` or `api.<api_id>.read_api`. Adding `decrypt: true` additionally needs `api.*.decrypt_key` or `api.<api_id>.decrypt_key`. See [Root key permissions](/platform/root-keys/permissions).

A missing permission isn't a 403. You get HTTP 404 [`err:unkey:data:api_not_found`](/errors/unkey/data/api_not_found), "The requested API does not exist or has been deleted." If you see this for an ID you know is correct, check both permissions on the root key.

## Request

<ParamField body="apiId" type="string" required>
  The keyspace to list, by API ID.
</ParamField>

<ParamField body="limit" type="integer" default="100">
  Keys per page, 1 to 100.
</ParamField>

<ParamField body="cursor" type="string">
  The `pagination.cursor` from the previous response. Omit for the first page.
</ParamField>

<ParamField body="externalId" type="string">
  Only keys linked to the identity with this exact `externalId`. Use it to list one user's keys.
</ParamField>

<ParamField body="decrypt" type="boolean" default="false">
  Include `plaintext` for keys created with `recoverable: true`. Requires the decrypt permission and a keyspace with encrypted storage enabled. See [Recoverable keys](/api-management/keys/recoverable-keys).
</ParamField>

<ParamField body="revalidateKeysCache" type="boolean" default="false">
  Read from the primary database to avoid replica lag. This can increase latency.
</ParamField>

```bash
curl -X POST https://api.unkey.com/v2/apis.listKeys \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "apiId": "api_...", "externalId": "user_123", "limit": 50 }'
```

## Response

`data` is an array of key objects, and `pagination` tells you whether to continue.

```json
{
  "meta": { "requestId": "req_..." },
  "data": [
    {
      "keyId": "key_...",
      "start": "sk_prod_abc1",
      "enabled": true,
      "name": "Production key",
      "createdAt": 1704067200000,
      "expires": 1735689600000,
      "meta": { "plan": "premium" },
      "credits": { "remaining": 950 },
      "identity": { "id": "id_...", "externalId": "user_123" }
    }
  ],
  "pagination": { "cursor": "...", "hasMore": true }
}
```

Each item has the same fields `keys.getKey` returns. `start` is the prefix and first few characters, so a user can recognize the key. `plaintext` appears only when `decrypt: true` worked for that key. [Looking up keys](/api-management/keys/looking-up-keys) lists every field.

## Paging

Send `pagination.cursor` back until `hasMore` is false. Cursors expire, so don't store them.

```typescript list-all-keys.ts
let cursor: string | undefined;
do {
  const { data, pagination } = await unkey.apis.listKeys({
    apiId: "api_...",
    externalId: "user_123",
    cursor,
  });
  for (const key of data) {
    console.log(key.keyId, key.start);
  }
  cursor = pagination.hasMore ? pagination.cursor : undefined;
} while (cursor);
```

## In the dashboard

The keyspace's **Keys** tab shows the same list. You can filter by key ID, name, identity, and tags (is, contains, starts with, ends with). Select several rows to change their external ID, enable or disable them, or delete them in bulk.

<Frame>
  <img src="/images/dashboard/api-management--keyspaces-listing-keys--filter.png" alt="Keys tab with the filter menu open, offering Name, Identity, Key ID, and Tags" />
</Frame>

<Frame>
  <img src="/images/dashboard/api-management--keyspaces-listing-keys--bulk-actions.png" alt="Keys tab with two keys selected and the bulk action bar offering Change External ID, Disable key, and Delete key" />
</Frame>

---

### Api Management Keyspaces Settings (`docs/api-management/keyspaces/settings.mdx`)

---
title: Keyspace settings
sidebarTitle: "Settings"
description: "Set the defaults and protections that apply to every key in a keyspace."
sources:
  - web/internal/db/src/schema/keyAuth.ts
  - web/internal/db/src/schema/apis.ts
  - svc/api/routes/v2_keys_create_key/handler.go
  - svc/api/routes/v2_keys_reroll_key/handler.go
  - svc/api/routes/v2_apis_delete_api/handler.go
  - internal/services/keys/get.go
  - internal/services/keys/validation.go
  - svc/api/openapi/spec/paths/v2/apis/getApi/V2ApisGetApiResponseData.yaml
  - web/apps/dashboard/lib/trpc/routers/api/create.ts
  - web/apps/dashboard/lib/trpc/routers/api/query-api-key-details.ts
  - web/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/settings/components/default-prefix.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/settings/components/default-bytes.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/settings/components/delete-protection.tsx
---

These settings apply to every key in a keyspace. Change them on the keyspace's **Settings** page in the dashboard. There's no API for this. For encrypted key storage and the IP allow list, contact support.

<Frame>
  <img src="/images/dashboard/platform--security-delete-protection--keyspace-settings.png" alt="Keyspace settings page with the name, API ID, keyspace ID, key defaults, and a Danger Zone holding the Delete Protection card and the Delete Keyspace button" />
</Frame>

## Name and identifiers

<ParamField path="name" type="string" required>
  The label shown in the dashboard. You can change it any time.
</ParamField>

<ParamField path="API ID" type="string">
  Read-only, `api_...`. Pass it as `apiId` when creating or listing keys.
</ParamField>

<ParamField path="Keyspace ID" type="string">
  Read-only, `ks_...`. Used by analytics (`key_space_id`) and the customer portal.
</ParamField>

## Key generation defaults

These apply when a `keys.createKey` request leaves out `prefix` or `byteLength`.

<ParamField path="Default prefix" type="string" default="none">
  Up to 16 characters. New keys without their own `prefix` look like `<prefix>_<random>`. Rerolling a key with no prefix also uses it.
</ParamField>

<ParamField path="Default bytes" type="integer" default="16">
  Random bytes in a new key when the request leaves out `byteLength`. The API accepts 16 to 255. Rerolled keys always use this value (or 16 if unset), not the original key's length.
</ParamField>

## Store encrypted keys

<ParamField path="Store encrypted keys" type="boolean" default="false">
  When on, you can create recoverable keys and read them back with `decrypt: true`. When off, `keys.createKey` with `recoverable: true` fails with HTTP 412 "This API does not support key encryption." There's no dashboard toggle. Contact support to turn it on. See [Recoverable keys](/api-management/keys/recoverable-keys).
</ParamField>

## IP allow list

<ParamField path="IP allow list" type="string" default="none">
  A comma-separated list of IP addresses, up to 512 characters. When set, a verification of any key in this keyspace fails with `code: FORBIDDEN` unless it comes from an IP on the list.
</ParamField>

Matching is exact, for IPv4 and IPv6. CIDR ranges such as `10.0.0.0/8` don't work. A request with no client IP is also rejected with `FORBIDDEN`.

New keyspaces have no allow list. Contact support to set or change one. You can't see the list in the dashboard or the API, so if verifications return `FORBIDDEN` and you don't know why, ask support whether an allow list is set.

## Delete protection

<ParamField path="Delete protection" type="boolean" default="false">
  When on, **Delete Keyspace** in the dashboard and `apis.deleteApi` both fail with HTTP 412 `err:unkey:application:protected_resource`. Switch it on the **Delete Protection** card and type the keyspace name to confirm. The change shows in the audit log as `api.update`.
</ParamField>

Protection covers the keyspace only. You can still delete keys inside it. [Delete protection](/platform/security/delete-protection) describes the same flag on the other resource types.

## Delete keyspace

The **Delete Keyspace** card in the danger zone deletes the keyspace after you type its name and the confirmation word. Every key in it then verifies as `NOT_FOUND`. You can't undo this from the dashboard.

---

### Api Management Portal End User Experience (`docs/api-management/portal/end-user-experience.mdx`)

---
title: What portal users can do
sidebarTitle: "End-user experience"
description: "What your users see and can do inside the portal."
sources:
  - web/apps/portal/src/routes/_portal/keys.tsx
  - web/apps/portal/src/components/keys-page/keys-page.tsx
  - web/apps/portal/src/components/keys-page/keys-page-model.ts
  - web/apps/portal/src/lib/scopes.ts
  - web/apps/portal/src/components/analytics/schema/analytics.schema.ts
  - svc/api/routes/v2_portal_get_verifications/handler.go
  - svc/api/openapi/spec/paths/v2/portal/createSession/V2PortalCreateSessionRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/portal/listKeys/V2PortalListKeysRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/portal/getVerifications/V2PortalGetVerificationsRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/portal/getVerifications/V2PortalGetVerificationsDataPoint.yaml
  - svc/api/routes/v2_portal_reroll_key/handler.go
  - svc/api/routes/v2_keys_create_key/handler.go
  - svc/api/routes/v2_keys_delete_key/handler.go
---

Here's what your users see in the portal. The scopes on their [session](/api-management/portal/sessions) decide what they can do, and they only ever see their own keys and usage.

## One page, shaped by scope

The portal is one Keys page. The scopes decide what's on it:

- **`keys:read`** shows the key table. Without it the page is empty.
- **`keys:reroll`** adds a roll-over action to each key.
- **`analytics:read`** adds a time filter, usage numbers, and an outcomes chart above the table, plus a request count on each key.

If the token expires while the user is on the page, the portal shows "Your session has expired" with a link to the session's `returnUrl`.

## Keys

The key table lists every key the user owns, with its name, status, creation date, and expiry. The full key is never shown.

With `keys:reroll`, the user can roll a key over. They pick a grace period, copy the new key (shown once), and the old key keeps working until the grace period ends.

## Usage

With `analytics:read`, the page shows verification counts over a time range, broken down by outcome, for all the user's keys or one of them. The time ranges on offer fit your workspace's log retention.

## Building your own portal front end

To build your own interface instead of using the hosted portal, call `portal.listKeys`, `portal.rerollKey`, and `portal.getVerifications` with the session's access token as a bearer token. They only return the session user's data.

### List keys

`portal.listKeys` returns the user's keys, up to 100 per page (`limit`, default 100, with `cursor` and `hasMore` as on every [list endpoint](/platform/api/overview)). There are no filters.

### Roll a key over

`portal.rerollKey` takes the same body as `keys.rerollKey`: the key and an `expiration` grace period for the old key. Another user's key returns not found.

### Read usage

`portal.getVerifications` takes `startTime` and `endTime` (Unix milliseconds, start included, end not) and an optional `keyId` for one key. A window longer than your log retention fails with `err:user:bad_request:query_range_exceeds_retention`.

The response has one series per key:

- `bucketMillis` is the bucket size (a minute, hour, or day, based on the window).
- `keys` has one entry per key with traffic in the window, with every bucket filled in (zeros included), in time order. Sum them for an account total.
- Keys with no traffic are left out. An entry can be for a key that's since been deleted, so don't assume you can look it up in `portal.listKeys`.

If more than 150 keys had traffic in the window, the request fails with [`per_key_breakout_too_large`](/errors/user/bad_request/per_key_breakout_too_large). Narrow the window or pass a `keyId`.

Each entry's `data` holds one point per bucket:

<ResponseField name="time" type="integer">
  Bucket start in Unix milliseconds.
</ResponseField>
<ResponseField name="total" type="integer">
  All verifications in the bucket.
</ResponseField>
<ResponseField name="valid" type="integer">
  Verifications with outcome `VALID`.
</ResponseField>
<ResponseField name="rateLimited" type="integer">
  Rejected because a rate limit was exceeded.
</ResponseField>
<ResponseField name="insufficientPermissions" type="integer">
  Rejected for missing permissions.
</ResponseField>
<ResponseField name="forbidden" type="integer">
  Rejected as forbidden.
</ResponseField>
<ResponseField name="disabled" type="integer">
  Rejected because the key was disabled.
</ResponseField>
<ResponseField name="expired" type="integer">
  Rejected because the key had expired.
</ResponseField>
<ResponseField name="usageExceeded" type="integer">
  Rejected because the key's credits were exhausted.
</ResponseField>

Asking for another user's key returns nothing.

### Create and delete keys

`keys.createKey` and `keys.deleteKey` also accept a portal token, so your interface can let users create and delete their own keys. A new key always belongs to the session's user, whatever the request says. Deleting another user's key returns not found. These actions show in the [audit log](/platform/workspace/audit-logs/overview) with a `portalEndUser` actor.

---

### Api Management Portal Sessions (`docs/api-management/portal/sessions.mdx`)

---
title: Portal sessions
sidebarTitle: "Sessions"
description: "Sign a user into the portal from your backend."
sources:
  - svc/api/routes/v2_portal_create_session/handler.go
  - svc/api/routes/v2_portal_exchange_code/handler.go
  - svc/api/routes/v2_portal_list_sessions/handler.go
  - svc/api/openapi/spec/paths/v2/portal/createSession/V2PortalCreateSessionRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/portal/createSession/index.yaml
  - svc/api/routes/v2_portal_update_portal/handler.go
  - svc/api/routes/v2_portal_delete_portal/handler.go
  - pkg/auth/portalrbac/portalrbac.go
  - pkg/codes/constants_gen.go
---

import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

Sign a user into the [developer portal](/api-management/portal/overview) from your backend, with no second login. After they sign in to your app, ask Unkey for a session for that user and redirect their browser to the URL you get back. Your root key never reaches the browser.

## Sign a user in

<Steps titleSize="h3">
  <Step title="Your backend mints a session">
    Call `portal.createSession` with the portal, the user's `externalId`, and the scopes they should have. You get `{ "id": "ps_...", "url": "https://portal.unkey.com/?code=pec_..." }`. The URL works **once**, within **15 minutes**.
  </Step>
  <Step title="Redirect the user">
    Send the browser to `url`.
  </Step>
  <Step title="The portal exchanges the code">
    The portal swaps the code for an access token that lasts **24 hours**. If the code is expired, already used, or unknown, the user sees `err:unkey:authentication:portal_session_not_found`, "Session is invalid, expired, or has already been used."
  </Step>
  <Step title="The user works, then leaves">
    The user only sees their own keys. When the token expires, or they click the return link, the portal sends them to the `returnUrl` you set. Without one, there's no return link. To let them back in, create a new session.
  </Step>
</Steps>

## Request

<RootKeyPrereq />

<ParamField body="portal" type="string" required>
  The portal's `pc_` ID or slug.
</ParamField>
<ParamField body="externalId" type="string" required>
  The user's ID in your system, 1 to 256 characters. A session for `user_123` shows the keys whose identity is `user_123`.
</ParamField>
<ParamField body="scopes" type="string[]" required>
  One or more of `keys:read`, `keys:reroll`, and `analytics:read`. Scopes decide what the user can see and do. See [What portal users can do](/api-management/portal/end-user-experience). `keys:reroll` and `analytics:read` each need `keys:read` too. Without it, the request fails with 400 and `err:unkey:application:invalid_input`.
</ParamField>
<ParamField body="returnUrl" type="string">
  Full URL, up to 500 characters, to send the user back to when they leave or the session expires. You set it per session, so different users can go back to different pages.
</ParamField>
<ParamField body="preview" type="boolean" default="false">
  Create a preview session to try the portal yourself.
</ParamField>

## Permissions your root key needs

Your root key needs `portal.*.create_portal_session` or `portal.<portalId>.create_portal_session`. Without it, the request fails with **404**, not 403.

It also needs a matching permission for each scope, on every keyspace the portal shows, either as `*` or for each `api_` ID:

| Scope | Required on the root key |
| --- | --- |
| `keys:read` | `read_key` and `read_api` |
| `keys:reroll` | `create_key`, plus `encrypt_key` if the keyspace stores encrypted keys |
| `analytics:read` | `read_analytics` |

If the root key is missing one, the whole request fails with **403**. A disabled portal can't create sessions at all.

## What ends a session

A session ends when its 24 hour token expires, or when the portal is disabled, deleted, or pointed at a different keyspace or app. That usually takes effect within about 10 seconds, but can take up to 5 minutes. Re-enabling a disabled portal doesn't bring its sessions back. To log one end user out early, call `portal.revokeSession` with the portal and their `externalId`. It ends every live session they have on that portal.

To see who is signed in, call `portal.listSessions` with the portal. It returns one entry per end user with a session that has not expired or been revoked, including sessions whose portal URL was not opened yet, and each end user's sessions and scopes. Results are ordered by `externalId`, paginated with `cursor`, and can be filtered with a case-sensitive `search` prefix. It needs a grant on the portal's sessions: `portal.*.create_portal_session`, `portal.<portalId>.create_portal_session`, or the portal's `sessions/*` with `read` or `write`. Reading the portal itself is not enough. Without it the request fails with **404**.

Creating, exchanging, and revoking sessions write `portal.session.create`, `portal.session.exchange`, and `portal.session.revoke` [audit log](/platform/workspace/audit-logs/event-types) entries. What the user does in the portal is logged under a `portalEndUser` actor.

## Errors

| Status | Code | Meaning |
| --- | --- | --- |
| 404 | `err:unkey:data:portal_not_found` | The portal does not exist, is not in your workspace, or your root key lacks `create_portal_session` for it. |
| 403 | `err:unkey:authorization:insufficient_permissions` | A requested scope needs a keyspace permission the root key lacks. |
| 401 | `err:unkey:authentication:portal_session_not_found` | On exchange: the code is expired, used, or unknown. On end-user endpoints: the token is expired or revoked. |
| 400 | `err:unkey:application:invalid_input` | `keys:reroll` or `analytics:read` was requested without `keys:read`. |
| 400 | `err:unkey:authentication:portal_token_missing` | An end-user endpoint was called without a portal token. |

---

### Api Management Ratelimiting How It Works (`docs/api-management/ratelimiting/how-it-works.mdx`)

---
title: How rate limiting works
sidebarTitle: "How it works"
description: "Know how requests are counted, what a rate limit response tells you, and how exact the counts are across regions."
sources:
  - internal/services/ratelimit/doc.go
  - internal/services/ratelimit/ratelimit.go
  - internal/services/ratelimit/interface.go
  - internal/services/keys/validation.go
  - svc/api/routes/v2_ratelimit_limit/handler.go
  - svc/api/openapi/spec/paths/v2/ratelimit/limit/V2RatelimitLimitResponseData.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
  - web/internal/api/src/sdk/ratelimit.ts
---

Unkey counts requests per identifier for you, so there's no counter store to run. Call `ratelimit.limit` with a user id or customer id, or put the limit on an API key and read it from `keys.verifyKey`. Both use the same sliding window.

When the identifier is over the limit, Unkey still returns HTTP 200. `ratelimit.limit` sets `data.success` to `false`. `keys.verifyKey` sets `data.code` to `RATE_LIMITED`. Your server returns `429 Too Many Requests` to the caller, with `Retry-After` in seconds. `reset` in the body is Unix milliseconds. Header names Unkey itself sends are on [Rate limiting](/api-management/ratelimiting/overview#what-a-limited-request-looks-like).

```typescript
import { Unkey } from "@unkey/api";

const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY });

export async function handler(request: Request) {
  const userId = request.headers.get("x-user-id") ?? "anonymous";
  const result = await unkey.ratelimit.limit({
    namespace: "api.requests",
    identifier: userId,
    limit: 100,
    duration: 60_000,
  });

  if (!result.data.success) {
    const retryAfter = Math.max(1, Math.ceil((result.data.reset - Date.now()) / 1000));
    return new Response("Too many requests", {
      status: 429,
      headers: { "Retry-After": String(retryAfter) },
    });
  }

  return new Response("ok");
}
```

A monthly total on an API key is credits, not this window. See [Credits and refill](/api-management/keys/credits-and-refill). The rest of this page explains how the count is computed.

## How requests are counted

A limit is `limit` requests per `duration` milliseconds. Unkey uses a sliding window, so a caller can't use a full limit at the end of one window and another full limit right after. It counts the current window plus part of the previous one, based on how much of the current window is left.

```text
previous window                 current window
|-------------------------------|-------------------------------|
                                       ^ now, 25% into the window

effective count = current + previous * (1 - 0.25)
allowed if effective count + cost <= limit
```

For example, with a limit of 100 per minute, a caller who made 100 requests at the end of one minute still counts as 75 fifteen seconds into the next (100 × 0.75), so the burst can't repeat right away. Denied requests don't count, so retrying while limited doesn't push the reset further away.

## Change a limit mid-window

Unkey stores counts per namespace, identifier and `duration`. The `limit` isn't part of that key. Unkey compares the stored count with whatever `limit` arrives on each call, so a new number applies on the very next request.

| What you change | What happens to the count |
| --- | --- |
| `limit`, with the same namespace, identifier and `duration` | Usage so far in the window is kept and checked against the new limit. No reset, and no wait for the window to end. |
| `duration` | A separate counter starts at zero. Usage under the old window length isn't carried over. |
| `identifier` or namespace | A separate counter starts at zero. |

For example, a caller on 100 per 5 hours has used 100 when they upgrade to 1,000. If your code sends `limit: 1000` with the same identifier and duration, their next request passes and `remaining` is about 899.

To give upgraded users higher limits right away, look up their plan when you make the call and send that plan's `limit`, keeping the identifier and `duration` the same. An [override](/api-management/ratelimiting/overrides) that changes only the limit behaves the same way. An override that changes `duration` starts a fresh count.

## Make some requests cost more

Each check spends `cost` tokens, 1 by default, so a heavy operation can use more of the limit than a light one. With `limit: 100`, a caller can make 100 checks at cost 1, 20 at cost 5, or any mix. A cost of 0 reads the current state without spending anything.

When one call checks several limits, a request denied by one limit doesn't use up the others.

## How exact the counts are across regions

Each region decides quickly using the traffic it has seen, and regions share counts within moments. In practice:

| Situation | What to expect |
| --- | --- |
| One region receives most of an identifier's traffic | Enforcement is tight. |
| Traffic is split across regions | A short burst can get through in more than one region before the counts catch up. |
| Part of our infrastructure is down | Limits stay on in each region, but a limit can let through more than usual until counts catch up. |

Rate limits protect you from bursts. They aren't an exact count. Use credits when you need an exact total. See [Credits and refill](/api-management/keys/credits-and-refill).

## What you get back

| Field | Meaning |
| --- | --- |
| `success` | Whether this request fit within the limit. In `ratelimit.multiLimit` the per-check field is `passed`. |
| `limit` | The limit that applied: yours, or an override's if one matched. |
| `remaining` | Tokens left in the current window, as an estimate; 0 when the check failed. |
| `reset` | Unix milliseconds when the current window ends. Capacity comes back gradually after this, not all at once. |
| `overrideId` | The override that supplied `limit` and `duration`, when one matched; empty otherwise. |

Trust `success` as the answer. Treat `remaining` as a close estimate, because another region may have accepted requests that aren't counted yet.

## What happens on errors

`ratelimit.limit` and `ratelimit.multiLimit` reject an empty identifier, a limit below 1, or a duration under 1000 milliseconds. If the check can't run, they return an error, and your code decides whether to allow or deny.

`keys.verifyKey` is different. If the rate limit check can't run, the verification carries on as if the limits passed, so an outage can't lock your users out. The same happens with an inline limit below 1 or a duration under 1000 milliseconds, so check those values before you send them. See [Verifying keys](/api-management/keys/verifying-keys).

---

### Api Management Ratelimiting Key And Identity Limits (`docs/api-management/ratelimiting/key-and-identity-limits.mdx`)

---
title: Key and identity rate limits
sidebarTitle: "Key and identity limits"
description: "Enforce named rate limits on a key or an identity during verification."
sources:
  - svc/api/openapi/spec/common/RatelimitRequest.yaml
  - svc/api/openapi/spec/common/RatelimitResponse.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/KeysVerifyKeyRatelimit.yaml
  - svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
  - svc/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyRequestBody.yaml
  - svc/api/routes/v2_keys_verify_key/handler.go
  - svc/api/routes/v2_keys_update_key/handler.go
  - internal/services/keys/get.go
  - internal/services/keys/validation.go
  - internal/services/keys/verifier.go
  - internal/services/ratelimit/ratelimit.go
  - web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/_components/create-key/create-key.schema.ts
  - svc/api/openapi/spec/common/KeyCreditsRefill.yaml
  - web/internal/api/src/sdk/keys.ts
---

Put a rate limit and a monthly request quota on each customer's API key, and `keys.verifyKey` enforces both. One call checks the key, the sliding-window limit, and the credit balance, with no Redis to run. To change a customer's numbers, call `keys.updateKey`. No deploy needed.

The rate limit is a named window on the key, such as 100 requests per 60,000 milliseconds. The monthly quota is credits. Set `credits.refill.interval` to `monthly` and the balance refills on `refillDay`. When the window is used up, `data.code` is `RATE_LIMITED`. When credits can't cover the request, `data.code` is `USAGE_EXCEEDED`. Unkey still returns HTTP 200, and your server returns the 429.

```typescript
import { Unkey } from "@unkey/api";

const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY });

export async function handler(request: Request) {
  const header = request.headers.get("authorization") ?? "";
  const key = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : header;
  const { data } = await unkey.keys.verifyKey({ key });

  if (!data.valid && data.code === "RATE_LIMITED") {
    const resetMs = data.ratelimits?.find((limit) => limit.exceeded)?.reset ?? Date.now();
    const retryAfter = Math.max(1, Math.ceil((resetMs - Date.now()) / 1000));
    return new Response("Too many requests", {
      status: 429,
      headers: { "Retry-After": String(retryAfter) },
    });
  }

  if (!data.valid && data.code === "USAGE_EXCEEDED") {
    return new Response("Monthly quota exceeded", { status: 429 });
  }

  return data.valid ? new Response("ok") : new Response("Unauthorized", { status: 401 });
}
```

`Retry-After` is in seconds, and <a href="https://www.rfc-editor.org/rfc/rfc6585#section-4" target="_blank">RFC 6585</a> defines both 429 and that header. `reset` on each checked limit is Unix milliseconds. `keys.verifyKey` doesn't set `Retry-After` or `X-RateLimit-*`. Which headers Unkey does send, including on the Compute gateway, is on [Rate limiting](/api-management/ratelimiting/overview#what-a-limited-request-looks-like).

A limit on an identity counts every key linked to that customer together. A limit on a key counts that key alone.

## Define limits

You set limits with `ratelimits` on `keys.createKey`, `keys.updateKey`, `identities.createIdentity`, and `identities.updateIdentity`, up to 50 per key or identity. Each entry has:

<ParamField body="name" type="string" required>
  3 to 128 characters, unique within the key or identity. You refer to the limit by this name at verification time.
</ParamField>

<ParamField body="limit" type="integer" required>
  Tokens per window, 1 or more.
</ParamField>

<ParamField body="duration" type="integer" required>
  Window length in milliseconds, 1000 or more.
</ParamField>

<ParamField body="autoApply" type="boolean" required>
  `true` checks this limit on every verification. `false` checks it only when a verification names it. Required: leaving it out fails with HTTP 400 and `missing property 'autoApply'`.
</ParamField>

```bash
curl -X POST https://api.unkey.com/v2/keys.createKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "apiId": "api_...",
    "ratelimits": [
      { "name": "requests", "limit": 100, "duration": 60000, "autoApply": true },
      { "name": "exports", "limit": 5, "duration": 3600000, "autoApply": false }
    ]
  }'
```

On `keys.updateKey` and `identities.updateIdentity`, `ratelimits` replaces the whole list. Limits you leave out are deleted and matching names are updated. `null` on `keys.updateKey` removes them all. Leave the field out to keep limits as they are.

## Check limits at verification

Auto-applied limits are always checked. To check any other limit, or to spend a custom cost, list it in the verification's `ratelimits` array.

<ParamField body="ratelimits[].name" type="string" required>
  The name of a limit on the key or its identity, 3 to 255 characters (saved limit names stop at 128; longer names only work for inline limits).
</ParamField>

<ParamField body="ratelimits[].cost" type="integer" default="1">
  Tokens to spend on this limit for this verification, 0 or more.
</ParamField>

<ParamField body="ratelimits[].limit" type="integer">
  With `duration`, defines an inline limit instead of referring to a configured one. See below.
</ParamField>

<ParamField body="ratelimits[].duration" type="integer">
  Window in milliseconds for an inline limit. Send it together with `limit`.
</ParamField>

```bash
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "key": "sk_live_...",
    "ratelimits": [
      { "name": "exports" },
      { "name": "requests", "cost": 3 }
    ]
  }'
```

All listed limits are checked together. If any is exceeded, none is used up, the verification fails with `code: RATE_LIMITED`, and no credits are spent. Naming a limit that isn't on the key or its identity fails the whole request with HTTP 412 [`err:unkey:application:precondition_failed`](/errors/unkey/application/precondition_failed).

If the rate limit check can't run, the verification carries on as if the limits passed, so an outage never blocks valid keys.

### Inline limits

An entry with both `limit` and `duration` is an inline limit, used for this call only. It counts against the key (never the identity), isn't saved, and has an empty `id` in the response. Use it when your code decides the numbers per endpoint.

<Warning>
An inline limit needs a `limit` of 1 or more and a `duration` of 1000 milliseconds or more. Below that you get no error. Instead the verification skips all rate limits, including auto-applied ones, and `data.ratelimits` comes back empty. Check these values in your code before you send them.
</Warning>

```json
{ "key": "sk_live_...", "ratelimits": [ { "name": "search", "limit": 20, "duration": 10000 } ] }
```

## What the response contains

Every checked limit appears in `data.ratelimits` with `id`, `name`, `limit`, `duration`, `remaining`, `reset` (Unix milliseconds), `exceeded`, and `autoApply`. If the key has an identity, `data.identity.ratelimits` lists all of the identity's limits, checked or not.

```json
{
  "meta": { "requestId": "req_..." },
  "data": {
    "valid": false,
    "code": "RATE_LIMITED",
    "keyId": "key_...",
    "ratelimits": [
      { "id": "rl_...", "name": "requests", "limit": 100, "duration": 60000, "remaining": 0, "reset": 1704067260000, "exceeded": true, "autoApply": true }
    ]
  }
}
```

## Key limits and identity limits

A limit on a key counts that key alone. A limit on an identity counts all its keys together, so five keys share one budget. If a key and its identity both have a limit with the same name, the key's limit wins, so you can give one key an exception to a shared limit. [Shared rate limits across keys](/api-management/identities/shared-rate-limits) walks through the identity side.

## From the dashboard

Set the same four fields in the **Create key** dialog's rate limit step, or with **Edit ratelimit** on a key or identity.

<Frame>
  <img src="/images/dashboard/api-management--ratelimiting-key-and-identity-limits--create-key-ratelimit.png" alt="Create key dialog on the Ratelimit step with the ratelimit toggle and the name, limit, and refill interval fields" />
</Frame>

---

### Api Management Ratelimiting Limit And Multi Limit (`docs/api-management/ratelimiting/limit-and-multi-limit.mdx`)

---
title: ratelimit.limit and multiLimit
description: "Rate limit any identifier with one call, or several limits at once."
sources:
  - svc/api/openapi/spec/paths/v2/ratelimit/limit/V2RatelimitLimitRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/ratelimit/limit/V2RatelimitLimitResponseData.yaml
  - svc/api/openapi/spec/paths/v2/ratelimit/limit/index.yaml
  - svc/api/openapi/spec/paths/v2/ratelimit/multiLimit/V2RatelimitMultiLimitRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/ratelimit/multiLimit/V2RatelimitMultiLimitCheck.yaml
  - svc/api/openapi/spec/paths/v2/ratelimit/multiLimit/V2RatelimitMultiLimitResponseData.yaml
  - svc/api/routes/v2_ratelimit_limit/handler.go
  - svc/api/routes/v2_ratelimit_multi_limit/handler.go
  - internal/services/ratelimit/ratelimit.go
  - internal/services/ratelimit/doc.go
  - pkg/zen/middleware_metrics.go
  - web/internal/db/src/schema/ratelimit.ts
  - web/internal/api/src/sdk/ratelimit.ts
---

import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

Call `ratelimit.limit` to rate limit a public API by user id, customer id, or IP address. Unkey keeps the counters, so you don't run Redis. You send the limit and the window on each call, and you return HTTP 429 when `data.success` is `false`.

`ratelimit.multiLimit` runs up to 100 checks in one call, and counts them only if they all pass. The rules live in your code. To change the rule for particular identifiers without a deploy, use [overrides](/api-management/ratelimiting/overrides). A monthly total on an API key is credits, not this window. See [Key and identity rate limits](/api-management/ratelimiting/key-and-identity-limits).

```typescript
import { Unkey } from "@unkey/api";

const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY });

export async function handler(request: Request) {
  const userId = request.headers.get("x-user-id") ?? "anonymous";
  const { data } = await unkey.ratelimit.limit({
    namespace: "api.requests",
    identifier: userId,
    limit: 100,
    duration: 60_000,
  });

  if (!data.success) {
    const retryAfter = Math.max(1, Math.ceil((data.reset - Date.now()) / 1000));
    return new Response("Too many requests", {
      status: 429,
      headers: { "Retry-After": String(retryAfter) },
    });
  }

  return new Response("ok");
}
```

The endpoint returns HTTP 200 either way. `data.reset` is Unix milliseconds. `Retry-After` is in seconds, which is what <a href="https://www.rfc-editor.org/rfc/rfc6585#section-4" target="_blank">RFC 6585</a> allows on a 429. Unkey doesn't set `Retry-After` or `X-RateLimit-*` on this endpoint. The full header list, including what the Compute gateway sends, is on [Rate limiting](/api-management/ratelimiting/overview#what-a-limited-request-looks-like).

<RootKeyPrereq />

The root key needs `ratelimit.*.limit` or `ratelimit.<namespace_id>.limit` for every namespace checked. The first call with a new namespace name creates it, which also needs `ratelimit.*.create_namespace`.

## Namespaces

A namespace names what you're limiting, such as `email.send` or `api.requests`. Identifiers are counted separately in each namespace. Refer to a namespace by name (1 to 512 characters, case-sensitive) or ID (`rlns_...`). A new name is created on first use. Rename or delete namespaces in the dashboard under **Ratelimit**. A check against a deleted namespace fails with HTTP 410 `err:unkey:data:ratelimit_namespace_gone`.

## ratelimit.limit

<ParamField body="namespace" type="string" required>
  Namespace name or ID, 1 to 512 characters.
</ParamField>

<ParamField body="identifier" type="string" required>
  Who or what you're counting, 1 to 512 characters: a user ID, IP address, tenant, or any stable string.
</ParamField>

<ParamField body="limit" type="integer" required>
  Maximum tokens per window, 1 or more. A matching override replaces it.
</ParamField>

<ParamField body="duration" type="integer" required>
  Window length in milliseconds, 1000 (one second) to 2592000000 (30 days). A matching override replaces it.
</ParamField>

<ParamField body="cost" type="integer" default="1">
  Tokens this request spends, 0 or more. 0 checks without counting.
</ParamField>

```bash
curl -X POST https://api.unkey.com/v2/ratelimit.limit \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "namespace": "api.requests",
    "identifier": "user_123",
    "limit": 100,
    "duration": 60000,
    "cost": 5
  }'
```

### Response

<ResponseField name="success" type="boolean" required>
  Whether the request fit within the limit. The endpoint returns HTTP 200 either way, so read this field.
</ResponseField>

<ResponseField name="limit" type="integer" required>
  The limit that applied, from the request or from a matching override.
</ResponseField>

<ResponseField name="remaining" type="integer" required>
  Tokens left in the current window, as a close estimate. 0 on denial.
</ResponseField>

<ResponseField name="reset" type="integer" required>
  Unix milliseconds when the current window ends.
</ResponseField>

<ResponseField name="overrideId" type="string">
  The override that was applied, when one matched.
</ResponseField>

```json
{
  "meta": { "requestId": "req_..." },
  "data": { "success": true, "limit": 100, "remaining": 95, "reset": 1704067260000 }
}
```

Every check is recorded in rate limit analytics and writes a `ratelimit.limit` audit event. To keep a `ratelimit.limit` call out of analytics and your API request logs, send the header `X-Unkey-Metrics: disabled`. `ratelimit.multiLimit` ignores this header.

## ratelimit.multiLimit

Send an array of 1 to 100 objects with the same fields as `ratelimit.limit`. Use it when one action must pass several limits, for example a per-user and a per-organization limit, or a request count and a token budget.

Each check must have a different combination of namespace, identifier and duration. A repeated combination returns a 400 error with `invalid_input` before any namespace is created.

```bash
curl -X POST https://api.unkey.com/v2/ratelimit.multiLimit \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '[
    { "namespace": "api.requests", "identifier": "user_123", "limit": 100, "duration": 60000 },
    { "namespace": "api.requests", "identifier": "org_acme", "limit": 1000, "duration": 60000 },
    { "namespace": "llm.tokens", "identifier": "user_123", "limit": 50000, "duration": 3600000, "cost": 1200 }
  ]'
```

If any check fails, none of them count. A request that passes counts against every limit. Overrides apply per check. A deleted namespace in any check fails the whole call with `err:unkey:data:ratelimit_namespace_gone`.

### Response

<ResponseField name="passed" type="boolean" required>
  `true` only when every check passed.
</ResponseField>

<ResponseField name="limits" type="object[]" required>
  One result per check, in request order, each with `namespace`, `identifier`, `passed`, `limit`, `remaining`, `reset`, and `overrideId`.
</ResponseField>

```json
{
  "meta": { "requestId": "req_..." },
  "data": {
    "passed": false,
    "limits": [
      { "namespace": "api.requests", "identifier": "user_123", "passed": true, "limit": 100, "remaining": 99, "reset": 1704067260000 },
      { "namespace": "api.requests", "identifier": "org_acme", "passed": true, "limit": 1000, "remaining": 999, "reset": 1704067260000 },
      { "namespace": "llm.tokens", "identifier": "user_123", "passed": false, "limit": 50000, "remaining": 0, "reset": 1704070800000 }
    ]
  }
}
```

Nothing was counted, so `remaining` on the passing checks doesn't include this request.

## Choosing limits

- **Pick the window for the job.** Short windows, such as 20 per 10 seconds, stop bursts. Longer windows, such as 1,000 per hour, work as quotas. Windows of a minute or more are more accurate across regions.
- **Use `cost` for expensive operations** instead of a second namespace.
- **Keep identifiers stable.** An identifier with a timestamp or request ID in it is never seen twice, so it limits nothing.
- **Send plan limits per call.** To raise limits when a user upgrades, send the new `limit` with the same identifier and `duration`. Usage already counted in the window carries over. See [Change a limit mid-window](/api-management/ratelimiting/how-it-works#change-a-limit-mid-window).
- **Use an [override](/api-management/ratelimiting/overrides)** when one customer needs a different number, instead of a code path.

---

### Api Management Ratelimiting Overrides (`docs/api-management/ratelimiting/overrides.mdx`)

---
title: Rate limit overrides
sidebarTitle: "Overrides"
description: "Give one identifier or a pattern its own limit without changing code."
sources:
  - svc/api/openapi/spec/paths/v2/ratelimit/setOverride/V2RatelimitSetOverrideRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/ratelimit/setOverride/V2RatelimitSetOverrideResponseData.yaml
  - svc/api/openapi/spec/paths/v2/ratelimit/getOverride/V2RatelimitGetOverrideRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/ratelimit/listOverrides/V2RatelimitListOverridesRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/ratelimit/deleteOverride/V2RatelimitDeleteOverrideRequestBody.yaml
  - svc/api/openapi/spec/common/RatelimitOverride.yaml
  - svc/api/routes/v2_ratelimit_set_override/handler.go
  - svc/api/routes/v2_ratelimit_limit/handler.go
  - pkg/match/wildcard.go
  - pkg/db/queries/ratelimit_override_insert.sql
  - web/internal/db/src/schema/ratelimit.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/ratelimits/[namespaceId]/_components/identifier-dialog.tsx
---

import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

An override gives particular identifiers a different limit from the one your code sends. Your app keeps calling `ratelimit.limit` with its default numbers, and when the identifier matches an override, Unkey uses the override's numbers instead. Use it to raise a partner's limit or throttle an abuser without deploying.

<RootKeyPrereq />

Setting an override needs `ratelimit.*.set_override` or `ratelimit.<namespace_id>.set_override`. Reading and listing need `ratelimit.*.read_override`, and deleting needs `ratelimit.*.delete_override`.

## Set an override

<ParamField body="namespace" type="string" required>
  Namespace name or ID, 1 to 512 characters. Unlike `ratelimit.limit`, this doesn't create a missing namespace. It returns HTTP 404 `err:unkey:data:ratelimit_namespace_not_found`.
</ParamField>

<ParamField body="identifier" type="string" required>
  The exact identifier, or a pattern containing `*`, 1 to 512 characters. Case-sensitive.
</ParamField>

<ParamField body="limit" type="integer" required>
  Tokens per window for matching identifiers, 1 or more. Don't use `0` to ban someone: the API accepts it, but checks against it fail with a server error. Use `1` with a long duration instead.
</ParamField>

<ParamField body="duration" type="integer" required>
  Window length in milliseconds, 1000 or more. It may differ from the duration your code sends.
</ParamField>

```bash
curl -X POST https://api.unkey.com/v2/ratelimit.setOverride \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "namespace": "api.requests",
    "identifier": "org_acme",
    "limit": 10000,
    "duration": 60000
  }'
```

```json
{
  "meta": { "requestId": "req_..." },
  "data": { "overrideId": "rlor_..." }
}
```

Setting an override on an identifier that already has one replaces it. The change shows in the audit log as `ratelimit.set_override`. It takes up to about a minute to apply everywhere.

<Visibility for="agents">
  The endpoint is `/v2/ratelimit.setOverride` (singular `ratelimit`) and the namespace is passed as `namespace`, by name or ID. The request has no separate namespace-id or namespace-name field and no asynchronous mode.
</Visibility>

## How a match is chosen

An override on the exact identifier always wins. If there isn't one, a matching wildcard override applies. In a pattern, `*` matches any run of characters (including none), and the pattern must match the whole identifier.

| Override | Matches | Does not match |
| --- | --- | --- |
| `*@acme.com` | `alice@acme.com`, `@acme.com` | `alice@acme.com.evil.io` |
| `enterprise:*` | `enterprise:123`, `enterprise:` | `pro:123` |
| `user_*_prod` | `user_1_prod`, `user__prod` | `user_1_staging` |
| `*suspicious*` | anything containing `suspicious` | |

With `*@acme.com` at 500 per minute and `ceo@acme.com` at 10,000, `ceo@acme.com` gets 10,000, everyone else at `acme.com` gets 500, and `user@other.com` gets the numbers your code sent. If two wildcards match the same identifier, there's no rule for which wins, so avoid overlapping wildcards in one namespace.

## Read, list, and delete

- **`ratelimit.getOverride`** takes `namespace` and `identifier` and returns the override that would apply, using the same matching rules: `overrideId`, `identifier` (the stored pattern), `limit`, and `duration`. If nothing matches, it returns HTTP 404 `err:unkey:data:ratelimit_override_not_found`.
- **`ratelimit.listOverrides`** takes `namespace`, `limit` (1 to 100, default 50), and `cursor`, and returns the same objects with pagination.
- **`ratelimit.deleteOverride`** takes `namespace` and `identifier`. Matching identifiers go back to your code's numbers (or another matching wildcard) within about a minute.

```bash
curl -X POST https://api.unkey.com/v2/ratelimit.deleteOverride \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "namespace": "api.requests", "identifier": "org_acme" }'
```

## From the dashboard

Under **Ratelimit**, open the namespace and its **Overrides** tab to add, edit, or delete overrides. You can also choose **Delete Override** on a row in the namespace's logs. In the dashboard, the identifier must be at least 2 characters and the limit can't go over 10,000. Overrides set through the API outside those bounds still work.

<Frame>
  <img src="/images/dashboard/api-management--ratelimiting-overrides--list.png" alt="Ratelimit namespace Overrides page listing overrides by identifier with their limits, and an Override Identifier button" />
</Frame>

<Frame>
  <img src="/images/dashboard/api-management--ratelimiting-overrides--add-override.png" alt="Override Identifier dialog with Identifier, Limit, and Duration fields" />
</Frame>

## Patterns that work well

- **Per-plan limits.** Build identifiers as `${plan}:${userId}` and set one wildcard override per plan (`free:*`, `pro:*`, `enterprise:*`). Update overrides from your billing webhooks when customers change plans.
- **Stop an attack.** Set the identifier to a limit of `1` over a long duration, and delete the override when the incident ends.

---

### Api Management Sdks Go (`docs/api-management/sdks/go.mdx`)

---
title: Go SDK
sidebarTitle: "Go"
description: "Manage keys, rate limits, and deployments from Go with the v3 client."
sources:
  - go.mod
  - cmd/api/util/client.go
  - cmd/api/util/errors.go
  - cmd/api/keys/verify_key.go
  - cmd/api/keys/create_key.go
  - cmd/api/ratelimit/limit.go
  - cmd/api/deployments/create_deployment.go
  - https://github.com/unkeyed/sdks/blob/49e455e0e6a3204f57ef325054438cf1bd7d6586/api/go/README.md
  - https://github.com/unkeyed/sdks/blob/49e455e0e6a3204f57ef325054438cf1bd7d6586/api/go/go.mod
  - https://pkg.go.dev/github.com/unkeyed/sdks/api/go/v3
---

import { versions } from "/snippets/versions.mdx";
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

Use the Go SDK, `github.com/unkeyed/sdks/api/go/v3` (current release {versions.goSdk}), to call the Unkey API from Go. The older `v1` and `v2` module paths are no longer updated.

<RootKeyPrereq />

## Install

```bash
go get github.com/unkeyed/sdks/api/go/v3
```

It needs a recent Go toolchain (currently Go 1.25).

## Construct the client

```go
import (
	unkey "github.com/unkeyed/sdks/api/go/v3"
)

client := unkey.New(
	unkey.WithSecurity(os.Getenv("UNKEY_ROOT_KEY")),
)
```

`WithSecurity` sets your root key. To change retries for every call, add `unkey.WithRetryConfig(retry.Config{...})`. For one call, pass `operations.WithRetries(...)` as the last argument. Request and response types are in `github.com/unkeyed/sdks/api/go/v3/models/components`, and typed errors in `.../models/apierrors`.

## Verify a key

```go
import (
	"errors"

	"github.com/unkeyed/sdks/api/go/v3/models/components"
)

res, err := client.Keys.VerifyKey(ctx, components.V2KeysVerifyKeyRequestBody{
	Key:         "prod_abc123",
	Permissions: unkey.String("documents.read"), // optional permission query
})
if err != nil {
	return err
}
if res.V2KeysVerifyKeyResponseBody == nil {
	return errors.New("unkey: empty response body")
}

data := res.V2KeysVerifyKeyResponseBody.Data
if !data.Valid {
	// data.Code says why: NOT_FOUND, DISABLED, EXPIRED, RATE_LIMITED, ...
}
```

The response field, here `V2KeysVerifyKeyResponseBody`, is a pointer, so check it for `nil` even when `err` is `nil`. Optional request fields are pointers or slices. Use helpers such as `unkey.String` and `unkey.Bool` for pointer values. An invalid key is a successful call with `Valid` false, not an `error`. The meaning of each field is covered in [Verifying keys](/api-management/keys/verifying-keys) and the schema in [keys.verifyKey](/api-management/api-reference/keys/verify-api-key).

## Create a key

```go
res, err := client.Keys.CreateKey(ctx, components.V2KeysCreateKeyRequestBody{
	APIID:       "api_1234abcd",
	Prefix:      unkey.String("prod"),
	Name:        unkey.String("Payment service"),
	ExternalID:  unkey.String("user_42"),
	Permissions: []string{"documents.read"},
	Enabled:     unkey.Bool(true),
})
if err != nil {
	return err
}

created := res.V2KeysCreateKeyResponseBody.Data
fmt.Println(created.KeyID)
fmt.Println(created.Key) // returned once; store it or hand it to the user now
```

`ByteLength`, `Meta`, `Roles`, `Expires`, `Credits`, `Ratelimits`, and `Recoverable` are the remaining fields. [Creating keys](/api-management/keys/creating-keys) explains them and [keys.createKey](/api-management/api-reference/keys/create-api-key) lists the schema.

## Apply a rate limit

```go
res, err := client.Ratelimit.Limit(ctx, components.V2RatelimitLimitRequestBody{
	Namespace:  "email.send",
	Identifier: "user_42",
	Limit:      10,
	Duration:   60_000, // milliseconds
})
if err != nil {
	return err
}
if !res.V2RatelimitLimitResponseBody.Data.Success {
	// Data.Remaining and Data.Reset (Unix ms) tell the caller when to retry
}
```

The endpoint is [ratelimit.limit](/api-management/api-reference/ratelimit/apply-rate-limiting).

## Create a deployment

The client covers Compute too. Name the project, app, and environment, and optionally set the source with one of `Oci`, `Git`, or `Deployment`. Leave all three out to use the app's default:

```go
res, err := client.Deployments.CreateDeploymentV3(ctx, components.V3DeploymentsCreateDeploymentRequestBody{
	Project:     "my-project",
	App:         "api",
	Environment: "production",
	Oci:         &components.DeploymentSourceOCI{Image: "ghcr.io/acme/api:v1.0.0"},
})
if err != nil {
	return err
}
fmt.Println(res.V3DeploymentsCreateDeploymentResponseBody.Data.DeploymentID)
```

The endpoint is [deployments.createDeploymentV3](/compute/api-reference/deployments/create-deployment). Don't use the `CreateDeployment` method. It calls the deprecated v2 endpoint. For projects, apps, and environments, see [Projects, apps, and environments](/compute/concepts/projects-apps-environments).

## Handle errors

Every method returns a response or an error, never both. API errors have a type per status that you can match with `errors.As`. Anything else is an `*apierrors.APIError`.

```go
import (
	"errors"

	"github.com/unkeyed/sdks/api/go/v3/models/apierrors"
)

res, err := client.Keys.GetKey(ctx, components.V2KeysGetKeyRequestBody{KeyID: "key_1234abcd"})
if err != nil {
	var notFound *apierrors.NotFoundErrorResponse
	var unauthorized *apierrors.UnauthorizedErrorResponse
	var apiErr *apierrors.APIError
	switch {
	case errors.As(err, &notFound):
		fmt.Println("not found:", notFound.Error_.GetDetail())
	case errors.As(err, &unauthorized):
		fmt.Println("check the root key:", unauthorized.Error_.GetDetail())
	case errors.As(err, &apiErr):
		fmt.Println(apiErr.StatusCode, apiErr.Message)
	default:
		return err // transport failure
	}
}
```

The typed errors are `BadRequestErrorResponse` (400, whose `Error_` is a `components.BadRequestErrorDetails` with a per-field `GetErrors()` list), `UnauthorizedErrorResponse` (401), `ForbiddenErrorResponse` (403), `NotFoundErrorResponse` (404), `ConflictErrorResponse` (409), `GoneErrorResponse` (410), `PreconditionFailedErrorResponse` (412), `UnprocessableEntityErrorResponse` (422), `TooManyRequestsErrorResponse` (429), `InternalServerErrorResponse` (500), and `ServiceUnavailableErrorResponse` (503). Each endpoint page in the API reference, for example [keys.getKey](/api-management/api-reference/keys/get-api-key), lists which ones apply.

## Next steps

<Columns cols={2}>
  <Card title="The unkey CLI" icon="terminal" href="/platform/cli/overview">
    The same calls from a shell, built on this module.
  </Card>
  <Card title="API reference" icon="code" href="/api-management/api-reference/keys/verify-api-key">
    Request and response fields for every method.
  </Card>
</Columns>

---

### Api Management Sdks Python (`docs/api-management/sdks/python.mdx`)

---
title: Python SDK
sidebarTitle: "Python"
description: "Manage keys and rate limits from Python with unkey.py."
sources:
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/py/README.md
  - https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/py/pyproject.toml
  - https://pypi.org/project/unkey.py/
---

import { versions } from "/snippets/versions.mdx";
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

Use `unkey.py` to call the Unkey API from Python. The current release is {versions.pySdk}, and it needs Python 3.10 or newer. Every method has a regular and an `_async` version. Methods are grouped as `unkey.keys`, `unkey.apis`, `unkey.identities`, `unkey.permissions`, `unkey.ratelimit`, `unkey.analytics`, and `unkey.portal`.

<RootKeyPrereq />

## Install

<CodeGroup>

```bash uv
uv add unkey.py
```

```bash pip
pip install unkey.py
```

```bash poetry
poetry add unkey.py
```

</CodeGroup>

## Construct the client

```python
import os
from unkey.py import Unkey

with Unkey(root_key=os.environ["UNKEY_ROOT_KEY"]) as unkey:
    ...
```

`root_key` is your root key. The client is a context manager that closes its connections on exit. In a long-running service, create one client and reuse it. To change retries, pass `retry_config=RetryConfig(...)` from `unkey.py.utils` to the client or to one call.

## Verify a key

<CodeGroup>

```python Sync
res = unkey.keys.verify_key(key="prod_abc123", permissions="documents.read")

if not res.data.valid:
    print(res.data.code)  # NOT_FOUND, FORBIDDEN, INSUFFICIENT_PERMISSIONS, USAGE_EXCEEDED, RATE_LIMITED, DISABLED, EXPIRED
```

```python Async
import asyncio
import os
from unkey.py import Unkey

async def main():
    async with Unkey(root_key=os.environ["UNKEY_ROOT_KEY"]) as unkey:
        res = await unkey.keys.verify_key_async(key="prod_abc123")
        print(res.data.valid)

asyncio.run(main())
```

</CodeGroup>

Request fields are `snake_case` keyword arguments (`tags`, `permissions`, `credits`, `ratelimits`). An invalid key isn't an exception: `data.valid` is false and `data.code` says why. See [Verifying keys](/api-management/keys/verifying-keys) for what each check means and [keys.verifyKey](/api-management/api-reference/keys/verify-api-key) for the schema.

## Create a key

```python
res = unkey.keys.create_key(
    api_id="api_1234abcd",
    prefix="prod",
    name="Payment service",
    external_id="user_42",
    permissions=["documents.read"],
    enabled=True,
)

print(res.data.key_id)
print(res.data.key)  # returned once; store it or hand it to the user now
```

The remaining fields (`byte_length`, `meta`, `roles`, `expires`, `credits`, `ratelimits`, `recoverable`) are described in [Creating keys](/api-management/keys/creating-keys), and the endpoint is [keys.createKey](/api-management/api-reference/keys/create-api-key).

## Apply a rate limit

```python
res = unkey.ratelimit.limit(
    namespace="email.send",
    identifier="user_42",
    limit=10,
    duration=60_000,  # milliseconds
)

if not res.data.success:
    ...  # res.data.remaining and res.data.reset (Unix ms) tell the caller when to retry
```

The endpoint is [ratelimit.limit](/api-management/api-reference/ratelimit/apply-rate-limiting).

## Handle errors

HTTP errors raise an `errors.UnkeyError`, with `message`, `status_code`, `headers`, `body`, and `raw_response`. Each status has its own subclass with a typed `data` attribute holding the API's `meta` and `error` fields.

```python
from unkey.py import Unkey, errors

try:
    res = unkey.keys.get_key(key_id="key_1234abcd")
except errors.NotFoundErrorResponse as e:
    print(e.data.meta.request_id, e.data.error.detail)
except errors.UnkeyError as e:
    print(e.status_code, e.message)
```

The subclasses are `BadRequestErrorResponse` (400), `UnauthorizedErrorResponse` (401), `ForbiddenErrorResponse` (403), `NotFoundErrorResponse` (404), `ConflictErrorResponse` (409), `GoneErrorResponse` (410), `PreconditionFailedErrorResponse` (412), `UnprocessableEntityErrorResponse` (422), `TooManyRequestsErrorResponse` (429), `InternalServerErrorResponse` (500), and `ServiceUnavailableErrorResponse` (503). Network failures raise `httpx.RequestError` subclasses such as `httpx.ConnectError` and `httpx.TimeoutException`. Each endpoint page in the API reference, for example [keys.getKey](/api-management/api-reference/keys/get-api-key), lists which errors it can return.

## Next steps

<Columns cols={2}>
  <Card title="Creating keys" icon="key" href="/api-management/keys/creating-keys">
    Every field a key can carry.
  </Card>
  <Card title="API reference" icon="code" href="/api-management/api-reference/keys/verify-api-key">
    Request and response fields for every method.
  </Card>
</Columns>

---

### Api Management Security Recovering Keys (`docs/api-management/security/recovering-keys.mdx`)

---
title: Recovering keys
description: "What you give up when a keyspace lets plaintext keys be read back."
sources:
  - web/internal/db/src/schema/keyAuth.ts
  - svc/api/routes/v2_keys_create_key/handler.go
  - svc/api/routes/v2_keys_get_key/handler.go
  - svc/api/routes/v2_apis_list_keys/handler.go
  - pkg/rbac/permissions.go
  - pkg/hash/sha256.go
  - pkg/encryption/aes.go
  - svc/vault/internal/vault/rpc_encrypt.go
---

Unkey stores every API key as a SHA-256 hash of the full key string, standard base64, with no salt. Verification hashes the presented key and compares the hashes. Unkey doesn't keep the plaintext unless you turn on recovery for that key. For background, see the <a href="https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html" target="_blank">OWASP Secrets Management Cheat Sheet</a> and the <a href="https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html" target="_blank">OWASP REST Security Cheat Sheet</a>. SHA-256 here isn't a password hash.

Recoverable keys let you show a key again after it's created. That comes with extra risk, so turn recovery on only when you need it: a dashboard that must show a key again, a migration that moves keys, or a support flow that reads a key back for a customer. By default you see a key only once, when it's created, and that's the right setup for most products. For the steps, see [Recoverable keys](/api-management/keys/recoverable-keys).

## How the encrypted copy is stored

A recoverable key is still verified by its hash. Beside the hash, Unkey stores an AES-256-GCM ciphertext of the key. The data key is 32 bytes, the nonce is 12 bytes, and the keyring is the workspace, so another workspace can't decrypt it. `keys.getKey` and `apis.listKeys` decrypt it only when you pass `decrypt: true`, and only with a root key that has `decrypt_key`.

A key is only recoverable if its keyspace has **Store encrypted keys** turned on (ask [support@unkey.com](mailto:support@unkey.com)) and the key was created with `recoverable: true` after that. Older keys can't be recovered.

## Two separate permissions

Creating a recoverable key needs `api.*.encrypt_key` or `api.<api_id>.encrypt_key` on top of `create_key`. Reading a key back needs `api.*.decrypt_key` or `api.<api_id>.decrypt_key` on top of `read_key`. Without `decrypt_key`, a root key can create and list keys but never read them back. Give `decrypt_key` only to the one service that needs to show keys, for the one keyspace that needs it.

## Limiting the exposure

With recovery on, a copy of our database is no longer useless on its own. Someone who also got your workspace's encryption keys, or a leaked root key with `decrypt_key`, could read your keys. To limit that:

- Only turn on recovery for keyspaces that need it.
- Never pass `decrypt: true` in code that serves end users.
- Use the [developer portal](/api-management/portal/overview), where users roll their own keys instead of reading them back.
- Watch the [audit log](/platform/workspace/audit-logs/overview) for unexpected `key.create` entries in keyspaces with encryption on.

If [rerolling](/api-management/keys/rerolling-keys) meets your need, use it instead. The user gets a fresh key and nobody reads the old one.

---

### Compute Build Build Secrets (`docs/compute/build/build-secrets.mdx`)

---
title: Build-time secrets
description: "Use your environment variables during a build without baking them into the image."
sources:
  - svc/ctrl/worker/deploy/build.go
  - svc/ctrl/worker/deploy/env_file.go
  - svc/ctrl/worker/deploy/railpack.go
  - svc/ctrl/worker/deploy/railpack_prepare.dockerfile.tmpl
  - svc/ctrl/worker/deploy/deploy_handler.go
  - pkg/validation/env_var.go
---

Your build can read every [environment variable](/compute/configure/environment-variables) of the <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environment</Tooltip> you're deploying, and none of them end up in the image. Automatic builds need no setup. Dockerfile builds need one extra line in each `RUN` step that uses them.

This is safer than Docker's `ARG` and `ENV`, which are saved in the image history where anyone with the image can read them.

## Automatic builds

When the environment builds without a Dockerfile, every variable is set as an environment variable of the same name for your install and build commands, the way it would be in CI. Change a value and the next deployment rebuilds the steps that use it.

## Dockerfile builds

In a Dockerfile build, your variables arrive as a `.env` file. Mount it in each `RUN` step that needs it:

```dockerfile Dockerfile
FROM node:lts-alpine AS builder
ARG UNKEY_SECRETS_ID
WORKDIR /app
COPY . .
RUN npm ci
RUN --mount=type=secret,id=${UNKEY_SECRETS_ID},target=/run/secrets/.env \
    set -a && . /run/secrets/.env && set +a && \
    npm run build

FROM node:lts-alpine
WORKDIR /app
COPY --from=builder /app .
CMD ["node", "dist/index.js"]
```

- **Declare `ARG UNKEY_SECRETS_ID` in every stage that mounts the file.** Docker doesn't carry `ARG` values across `FROM` lines.
- **`set -a && . /run/secrets/.env && set +a`** loads the variables for that one `RUN` step only. The file isn't saved in the layer.
- **Changing a variable reruns that step** and every step after it in the stage. Steps before it stay cached.

The file uses standard `.env` syntax, one `KEY=value` per line. Values with spaces, quotes, `$`, or newlines are single-quoted, so PEM keys and JSON come through unchanged. If your framework reads `.env` files itself, point it at `/run/secrets/.env` and make sure its parser handles quoted multi-line values.

## Troubleshooting

### A build step keeps using an old value

Older Dockerfiles mount the secret with the fixed id `env`. That still builds, but changing a variable doesn't rerun the step, so the old value can stay cached. Change the mount to `id=${UNKEY_SECRETS_ID}`.

### Cloning a private repository or installing a private package fails

We fetch only the repository being built, with read-only access. A `RUN git clone` of another private repository, a private submodule, or a private package registry isn't authenticated. Add the token it needs as an environment variable and use it in the step that needs it.

## Next steps

<Columns cols={2}>
  <Card title="Environment variables" icon="key" href="/compute/configure/environment-variables">
    Kinds, limits, import, and how values reach running instances.
  </Card>
  <Card title="Dockerfile builds" icon="file-code" href="/compute/build/dockerfile">
    Build context, the runtime contract, and explained errors.
  </Card>
</Columns>

---

### Compute Build Dockerfile (`docs/compute/build/dockerfile.mdx`)

---
title: Dockerfile builds
description: "Build with your own Dockerfile when automatic detection is not enough."
sources:
  - svc/ctrl/worker/deploy/deploy_handler.go
  - svc/ctrl/worker/deploy/build.go
  - svc/krane/internal/deployment/apply.go
  - svc/api/openapi/spec/paths/v2/environments/updateSettings/V2EnvironmentsUpdateSettingsRequestBody.yaml
  - svc/api/routes/v2_environments_update_settings/handler.go
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/build-settings/dockerfile-settings.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/build-settings/root-directory-settings.tsx
---

By default, we [build your app](/compute/build/overview) without a Dockerfile. Use your own Dockerfile when you need something automatic detection can't do, such as system packages, custom build stages, an unusual toolchain, or full control of the image.

## Switch an environment to a Dockerfile

1. Open the <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip>, go to **App settings** > **Build**, and find the **Dockerfile** row.
2. Pick a Dockerfile from the list of ones we found in your repository, or type its path. The path is relative to the root directory and can be 1 to 500 characters. If the file isn't on the tracked branch, you'll see "File not found on this branch".
3. Deploy. The setting applies from the next deployment.

With the API, set `dockerfile` on `environments.updateSettings`.

Production and preview have separate build settings, so one <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environment</Tooltip> can use a Dockerfile while the other builds automatically. The dashboard's **Build** page saves to both environments, so set a different value per environment with the API.

To go back to automatic builds, choose **Automatic (no Dockerfile)** in the dashboard, or set `dockerfile` to `null` in the API.

## Set the build context

The root directory is the build context. `COPY` and `ADD` paths, and the Dockerfile path, are resolved from it. The default `.` is the repository root.

For a service in `services/api`, you have two options:

- Set the root directory to `services/api` and the Dockerfile path to `Dockerfile`.
- Keep the root at `.` and set the Dockerfile path to `services/api/Dockerfile`, if the build needs files from elsewhere in the repository.

The root directory must be a relative path like `services/api`. It can't start with `/` or `./`, or contain `..`, backslashes, or spaces.

## What your container must do

- **Listen on `PORT`.** We set this environment variable to the environment's port (8080 by default), and it overrides any `ENV PORT` in your image.
- **Exit cleanly on the shutdown signal**, `SIGTERM` unless you change it, so instances can drain when they're replaced.
- **Keep local writes under 128 MiB.** That's the cap on the container's own filesystem. Use the optional disk at `/data` for anything larger.

If your image's default command isn't what you want, set the **Start command** in [runtime settings](/compute/configure/runtime-settings) instead of keeping a second Dockerfile.

## Why your Dockerfile build failed

A failed build ends the deployment as `failed` with the code `build_failed`. The deployment page shows a plain message for common causes, with the full build log underneath:

| What went wrong | Message you see |
| --- | --- |
| The Dockerfile path points at an empty file | The Dockerfile appears to be empty. Please verify the file path in settings. |
| The Dockerfile could not be read at the configured path | Dockerfile could not be read. Please check that the file path is correct in settings. |
| A file the Dockerfile references is missing from the build context | A file or directory referenced in the build was not found. Please check the root directory in settings. |
| A `COPY` or `ADD` source is missing | A file referenced in the Dockerfile was not found. Please check the root directory in settings. |
| The requested build target stage doesn't exist | The specified build target stage was not found. Please check the target name in settings. |
| The Dockerfile has a syntax error | Dockerfile has a syntax error. Please check the Dockerfile for typos. |
| There is no `FROM` instruction | Dockerfile has no valid build stage. Please add a FROM instruction. |
| A `FROM` image reference is malformed | A Docker image reference is invalid. Please check your FROM lines. |
| The base image has no build for the target platform | The base image does not support the target platform. Please use a multi-platform image or change the platform. |
| An undeclared `ARG` is referenced | Dockerfile references an undefined variable. Please check your ARG declarations. |
| A `RUN` command exited non-zero | A build command failed. Please check the build logs for details. |

Errors we don't recognize show the raw build error.

If your Dockerfile clones another private repository or a private submodule, it gets a 404 from GitHub. We only have read access to the repository being built. Vendor those dependencies, or fetch them with credentials passed as [build-time secrets](/compute/build/build-secrets).

## Next steps

<Columns cols={2}>
  <Card title="Build-time secrets" icon="key" href="/compute/build/build-secrets">
    Mount your environment variables into `RUN` steps safely.
  </Card>
  <Card title="Runtime settings" icon="sliders" href="/compute/configure/runtime-settings">
    Port, command, shutdown signal, CPU, memory, and disk.
  </Card>
</Columns>

---

### Compute Build Github (`docs/compute/build/github.mdx`)

---
title: GitHub integration
description: "Connect a GitHub repository so every push becomes a deployment."
sources:
  - svc/api/openapi/spec/paths/v2/github/installApp/index.yaml
  - svc/api/openapi/spec/common/AppGitCreateInput.yaml
  - svc/api/openapi/spec/common/AppGitUpdateInput.yaml
  - svc/api/openapi/spec/paths/v2/deployments/createDeployment/DeploymentSourceGit.yaml
  - svc/ctrl/api/webhooks/github/github.go
  - svc/ctrl/api/webhooks/github/push.go
  - svc/ctrl/api/webhooks/github/pull_request.go
  - svc/ctrl/worker/githubwebhook/handle_push.go
  - svc/ctrl/worker/deployment/authorize_handler.go
  - svc/ctrl/internal/db/github_repo_connection_list_deploy_context.sql_generated.go
  - svc/ctrl/worker/githubstatus/init_handler.go
  - svc/ctrl/worker/githubstatus/report_handler.go
  - svc/ctrl/worker/githubstatus/comment.go
  - svc/ctrl/worker/deploy/deploy_handler.go
  - svc/ctrl/worker/deploy/domains.go
  - svc/ctrl/worker/deploy/build.go
  - pkg/match/watchpath.go
  - web/internal/db/src/schema/github_app.ts
  - web/apps/dashboard/lib/trpc/routers/github.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/settings/components/disconnect-github.tsx
---

Connect a GitHub repository to an <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip> and every push becomes a deployment. You install the Unkey GitHub App once, connect a repository, and push.

## Install the GitHub App

You install the Unkey GitHub App once per workspace, on a GitHub organization or personal account, and choose which repositories it can see.

1. Start from any of these places in the dashboard: **Connect GitHub** when you create an app, the **Repository** row on an app's **App settings** > **Build** page, or [workspace settings](/platform/workspace/settings).
2. Approve the installation on GitHub. GitHub sends you back to Unkey.

To do it with the API, call `github.installApp` with a root key that has `workspace.*.install_github`, and open the URL it returns.

You can install the app on more than one GitHub account. An installation belongs to one workspace only, so a second workspace can't use it.

## Connect a repository to an app

Pick the repository when you create the app, or later under **App settings** > **Build**. From the CLI, pass `--git` to [create-app](/compute/cli/apps/create-app) or [update-app](/compute/cli/apps/update-app).

You connect a repository per app, not per project. In a monorepo, two apps can connect to the same repository and use different build settings.

The app's default branch deploys to production. It starts as the repository's default branch on GitHub, and you can change it with `apps.updateApp`.

To stop deploying from pushes, click **Disconnect repository** in the danger zone of the app's **App settings** > **General** page. The dashboard warns that "Unkey will stop building pushes to this repository. Running deployments stay live."

## What deploys when you push

Two things on GitHub create a deployment:

- **A push to a branch.** The first push of a new branch counts, which is how a branch gets its first preview. Pushing a tag or deleting a branch doesn't deploy.
- **A pull request from a fork**, when it's opened or gets new commits. Pull requests from branches in your own repository don't deploy again, because the push already did.

The branch picks the <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environment</Tooltip>. The app's default branch (or `main` if none is set) deploys to production. Every other branch, and every fork pull request, deploys to preview. If several apps are connected to the repository, one push creates one deployment per app.

## Skip deploys with auto deploy and watch paths

Two [build settings](/compute/configure/build-settings) can stop a push from deploying. We check them in this order:

1. **Auto deploy off.** The push is recorded as `skipped` with "Auto deploy is disabled for this environment." Watch paths aren't checked.
2. **Watch paths set, and no changed file matches.** The push is skipped with "Watch paths did not match any changed files." If a pattern isn't a valid glob, it's skipped with a message naming the pattern.

Otherwise, we create a deployment. Watch paths also apply to fork pull requests.

Patterns use `**` globbing. `src/**` matches everything under `src`, `**/*.go` matches every Go file, and a pattern with no wildcard matches only that exact path.

## Approve pull requests from forks

A fork pull request runs code from someone without write access to your repository, with your environment variables. So we never build it automatically.

1. The deployment waits in `awaiting_approval`. On the pull request, a failing check named **Unkey Deploy Authorization** says "Awaiting authorization from a project member" and links to the deployment.
2. A project member approves it in the dashboard. The build starts.

Pushes from people with write access to the repository don't need approval.

Fork previews get their own hostname prefix, `<project>[-<app>]-fork-<fork owner>-...`, so you can't confuse them with your own branches. You can also deploy a fork yourself from the **Create Deployment** dialog, or with `deployments.createDeployment` by passing `git.repository` as `owner/repo` and a `commitSha`.

## What you'll see on GitHub

Each deployment of a connected app reports back to GitHub:

- **A GitHub Deployment** on the commit, labeled `project - env` (or `project/app - env` when the app isn't `default`), linking to the environment's `unkey.app` domain. Its status goes from "Deployment queued" to "Deploying to regions...", "Configuring routing...", and "Deployment is live", or to a failure that names the step that failed.
- **A pull request comment**, when the branch has an open pull request or the deployment came from a fork. We keep one comment per pull request up to date, with a row per app and environment: status, a **Visit Preview** link, an **Inspect** link to the dashboard, and when it last changed.
- **A commit status**, only for fork deployments waiting for approval.

## Troubleshooting: a push didn't deploy

Check these in order:

1. The Unkey GitHub App is installed on the account that owns the repository, and has access to it.
2. The repository is connected to the app under **App settings** > **Build**.
3. Auto deploy is on for the environment the branch maps to (the **Auto-deploy** switch on the environment's page under **App settings** > **Environments**).
4. The push changed a file that matches the watch paths, if any are set.
5. The app's deployments list shows a `skipped` or `failed` row with the reason.

## Next steps

<Columns cols={2}>
  <Card title="Build settings" icon="sliders" href="/compute/configure/build-settings">
    Root directory, Dockerfile, build command, watch paths, auto deploy.
  </Card>
  <Card title="Production and preview" icon="code-branch" href="/compute/concepts/production-and-preview">
    What happens after a deployment on either branch kind reaches ready.
  </Card>
</Columns>

---

### Compute Cli Deploy (`docs/compute/cli/deploy.mdx`)

---
title: unkey deploy
sidebarTitle: "deploy"
description: "Deploy a prebuilt container image to a project with one command."
sources:
  - cmd/deploy/main.go
  - cmd/deploy/control_plane.go
  - cmd/api/util/client.go
  - svc/api/routes/v2_deploy_create_deployment/handler.go
  - svc/api/routes/v2_deploy_get_deployment/handler.go
  - docs/scripts/manifest.json
---

import RootKeyPrereq from "/snippets/root-key-prereq.mdx";

<RootKeyPrereq />

Deploy a prebuilt container image to an <Tooltip tip="A Compute app: a deployable service inside a project. Not your application in general.">app</Tooltip> and wait until it's live or has failed. It's a shortcut for [create-deployment](/compute/cli/deployments/create-deployment) followed by polling [get-deployment](/compute/cli/deployments/get-deployment).

<Warning>
  `unkey deploy` relies on endpoints that aren't part of the public API reference and can change without notice. For scripts that must not break, use the `unkey api deployments` commands, which call the stable `deployments.*` endpoints.
</Warning>

## Usage

```bash
unkey deploy <docker-image> --project=<project> --root-key=<root key> [flags]
```

The image is required. Without it, the command exits with `docker image is required`. The image must already be in a registry we can pull from. Nothing is built.

## Flags

<ParamField body="--project" type="string" required>
  Project slug the app belongs to. Falls back to `UNKEY_PROJECT`.
</ParamField>

<ParamField body="--app" type="string" default="default">
  App slug within the project. Falls back to `UNKEY_APP`.
</ParamField>

<ParamField body="--env" type="string" default="preview">
  Slug of the <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environment</Tooltip> to deploy to. Pass `production` to deploy to production.
</ParamField>

<ParamField body="--keyspace-id" type="string">
  Keyspace whose keys the gateway accepts for this deployment. Falls back to `UNKEY_KEYSPACE_ID`. See [API key authentication](/compute/gateway/api-key-auth).
</ParamField>

<ParamField body="--branch" type="string" default="main">
  Git branch recorded on the deployment. If you leave the default and you're in a Git checkout on another branch, the CLI uses that branch.
</ParamField>

<ParamField body="--commit" type="string">
  Commit SHA recorded on the deployment. Defaults to the HEAD of your Git checkout. Outside a Git repository, no commit is recorded unless you pass this. If you pass a SHA other than HEAD, only the SHA is recorded, without the commit message or author.
</ParamField>

<ParamField body="--root-key" type="string" required>
  Root key. Falls back to `UNKEY_ROOT_KEY`. Unlike the `unkey api` commands, `unkey deploy` doesn't read `~/.unkey/config.toml`, so a key saved with `unkey auth login` doesn't work here.
</ParamField>

<ParamField body="--api-base-url" type="string">
  API base URL. Falls back to `UNKEY_API_BASE_URL`. When empty, `https://api.unkey.com` is used. You don't normally need to set it.
</ParamField>

The command doesn't accept `--output`, `--config`, or `--body`.

## Required permissions

`project.*.create_deployment` or `project.<project_id>.create_deployment` to create the deployment, and `project.*.read_deployment` or `project.<project_id>.read_deployment` to follow it. These are project permissions. The `unkey api deployments` commands use different permissions, listed on each command's page. See [Root key permissions](/platform/root-keys/permissions).

## What you'll see

The command prints a `Deployment Progress` header with the branch, commit, and image. Inside a Git repository, it records the branch, commit, message, author, and commit time on the deployment so the dashboard can show them, and marks the commit as dirty if you have uncommitted changes.

If the deployment can't be created, the command prints the error and exits 1. Common causes are a `--project` or `--app` that doesn't exist, an `--env` that isn't an environment of that app, a root key without `create_deployment`, an image reference we can't parse, or a `--keyspace-id` that isn't in your workspace.

Otherwise it follows the deployment until it finishes:

- **Ready:** prints `Deployment completed successfully`, the deployment ID, the environment, and its hostnames, and exits 0.
- **Failed:** prints the error message (or `Unknown deployment error`) and exits 1, so a CI job fails.
- **Still going after 5 minutes:** exits with `deployment timeout after 5 minutes`. The deployment keeps going. Follow it with `unkey api deployments get-deployment`.

## Examples

Deploy to the preview environment of the default app:

```bash
unkey deploy ghcr.io/acme/payments:1.4.2 --project=payments --root-key=unkey_xxx
```

Deploy to production with the key from the environment, as in CI:

```bash
export UNKEY_ROOT_KEY=unkey_xxx
unkey deploy myregistry.io/payments-api:latest --project=payments --app=payments-api --env=production
```

Deploy behind API key authentication:

```bash
unkey deploy ghcr.io/acme/payments:1.4.2 --project=payments --app=payments-api --env=production --keyspace-id=ks_1234abcd
```

## Related

<Columns cols={2}>
  <Card title="Deploy an image" href="/compute/get-started/deploy-an-image">
    The guided walkthrough from first image to first request.
  </Card>
  <Card title="Deployments" href="/compute/concepts/deployments">
    Statuses, the build queue, and why a deployment fails.
  </Card>
  <Card title="CLI authentication" href="/platform/cli/authentication">
    Where the root key comes from and why `unkey deploy` differs.
  </Card>
</Columns>

---

### Compute Cli Healthcheck (`docs/compute/cli/healthcheck.mdx`)

---
title: unkey healthcheck
sidebarTitle: "healthcheck"
description: "Probe a URL and exit non-zero unless it answers 200, for scripts and CI."
sources:
  - cmd/healthcheck/main.go
  - docs/scripts/manifest.json
---

Send one HTTP GET request to a URL and exit 0 if the server answers 200, or exit 1 otherwise. The command needs no root key and never contacts the Unkey API. Use it as a small probe for CI pipelines, load balancer checks, and monitoring scripts, for example against the hostname of a deployment.

## Usage

```bash
unkey healthcheck <url>
```

The URL is required. Without it, the command exits 1 with `you must provide a url like so: 'unkey healthcheck <url>'`. The command has no flags.

## Behavior

Redirects are followed and there's no request timeout, so a server that never answers keeps the command waiting. A 200 response prints `✓ Healthcheck passed: <url> returned 200` and exits 0. Any other status exits 1 with `healthcheck failed with status code <code>`. A connection error exits 1 with `failed to perform healthcheck:` followed by the underlying error.

## Examples

Probe a deployment hostname:

```bash
unkey healthcheck https://payments-payments-api-production-acme.unkey.app/health
```

Alert when a service is down:

```bash
unkey healthcheck https://example.com/api/status || echo 'Service is down!'
```

Gate a pipeline step:

```bash
unkey healthcheck http://localhost:8080/health && npm test
```

---

### Compute Concepts Deployments (`docs/compute/concepts/deployments.mdx`)

---
title: Deployments on Unkey
sidebarTitle: "Deployments"
description: "Unkey builds a deployment from GitHub, an image, or the CLI."
sources:
  - pkg/mysql/types/deployment_status.go
  - pkg/mysql/types/deployment_desired_state.go
  - svc/api/openapi/spec/common/Deployment.yaml
  - svc/api/openapi/spec/common/DeploymentStatus.yaml
  - svc/api/openapi/spec/common/DeploymentErrorCode.yaml
  - svc/api/openapi/spec/paths/v2/deployments/createDeployment/V2DeploymentsCreateDeploymentRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/deployments/createDeployment/DeploymentSourceGit.yaml
  - svc/ctrl/worker/deploy/create.go
  - svc/ctrl/worker/deployment/cancel_handler.go
  - svc/ctrl/worker/deploy/supersede.go
  - svc/ctrl/worker/deploy/doc.go
  - svc/ctrl/worker/deploy/deploy_handler.go
  - svc/ctrl/worker/deploy/deployment_step.go
  - svc/ctrl/worker/deploy/readiness.go
  - svc/ctrl/worker/githubwebhook/handle_push.go
  - pkg/deploy/deployfail/messages.go
  - web/apps/dashboard/lib/collections/deploy/deployment-status.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/deployments/components/table/components/actions/deployment-action-eligibility.ts
  - svc/ctrl/worker/deploy/build_handler.go
---

Unkey creates a deployment from a GitHub push, a container image, the dashboard, the CLI, or `deployments.createDeployment`. That deployment keeps the image, runtime settings, environment variables, and gateway policies it started with. With auto deploy on and no watch paths, the default branch becomes a production deployment and any other branch becomes a preview. The deployment can enforce API keys and rate limits from API management through its gateway policies.

## How a deployment starts

The source you pass decides what gets built. For a GitHub push, auto deploy must be on. If you set watch paths, a changed file must match one of them, or Unkey records the push as `skipped`.

| How you ship | What Unkey creates |
| --- | --- |
| Push to the connected default branch | A production deployment |
| Push to any other branch | A preview deployment |
| Pull request from a fork | A preview deployment that waits in `awaiting_approval` until a project member approves it |
| Prebuilt image (`oci`) | A deployment of the tag or digest you set, or `latest` if you set neither. Later moves of that tag don't change it |
| Dashboard, CLI, or API, with no source | A git app builds its default branch. An image app deploys its default image |

A <Tooltip tip="One built and running version of an app in one environment.">deployment</Tooltip> is one built version of an <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip> in one <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environment</Tooltip>. You can't change a deployment once it's created. It keeps the image, runtime settings, environment variables, and gateway policies it started with, so a change to any of them applies from the next deployment.

## Create a deployment

A push to a connected GitHub repository creates a deployment for you. With auto deploy on and no watch paths, Unkey deploys from GitHub on every push. The default branch becomes a production deployment, and any other branch becomes a preview deployment. Watch it on the deployment page. The status moves through `pending`, `building`, and `deploying` to `ready`, and the page lists the domains. See [Deploy from GitHub](/compute/get-started/deploy-from-github) and [GitHub integration](/compute/build/github).

To deploy by hand, open the app's **Deployments** page and click **Create deployment**, or click **Redeploy** on an existing deployment. From the CLI or API, use [`unkey api deployments create-deployment`](/compute/cli/deployments/create-deployment) or `deployments.createDeployment` with the project, app, and environment. With no source, a git app builds its default branch and an image app deploys its default image. To deploy something else, pass one source:

- `git`: build from the connected repository. Set `branch` for the latest commit on a branch, `commitSha` for an exact commit, or `repository` with a `commitSha` to build a fork.
- `oci`: run a prebuilt image as it is. Give it a tag or a digest, or you get `latest`. The deployment keeps the image the tag pointed to when you deployed, so moving the tag later doesn't change it.
- `deployment`: run an existing deployment again. A git app rebuilds the same commit, and an image app reuses the same image. This is what **Redeploy** does.

The dashboard shows a badge for how each deployment was started: GitHub, API, CLI, or dashboard.

<Frame>
  <img src="/images/dashboard/compute--concepts-deployments--list.png" alt="Deployments page listing deployments with status, environment, and branch, with one row's menu open showing Rollback, Promote, Redeploy, Cancel deployment, and links to requests, logs, and build logs" />
</Frame>

## Statuses

A deployment moves through these statuses. Six of them are final: `ready`, `failed`, `skipped`, `stopped`, `superseded`, and `cancelled`. The other seven mean it's still in progress.

```mermaid
stateDiagram-v2
    [*] --> pending : push, API, CLI, or dashboard
    [*] --> awaiting_approval : pull request from a fork
    [*] --> skipped : auto deploy off, or no watch path matched
    awaiting_approval --> pending : approved by a project member
    awaiting_approval --> superseded : newer commit on the same branch
    pending --> superseded : newer commit on the same branch
    pending --> starting : its turn in the queue
    state "In flight" as inflight {
        starting --> building
        building --> deploying : image ready
        deploying --> network : instances running
        network --> finalizing : domains assigned
    }
    finalizing --> ready
    inflight --> failed
    pending --> cancelled : cancel
    awaiting_approval --> cancelled : cancel
    inflight --> cancelled : cancel
    ready --> stopped : stop, idle preview, or spend cap
    stopped --> deploying : start a stopped preview deployment
```

| Status | What's happening |
| --- | --- |
| `pending` | Waiting for its turn. See [Why a deployment is waiting](#why-a-deployment-is-waiting). |
| `awaiting_approval` | A pull request from a fork, held until a project member approves it. |
| `starting` | Its turn has come and it's getting started. |
| `building` | Building the image from your repository. A deployment from a prebuilt image has nothing to build, so it passes through quickly. |
| `deploying` | Starting instances in each of the environment's regions and waiting for them to become healthy. |
| `network` | Assigning the deployment's domains. The dashboard labels this **Assigning Domains**. |
| `finalizing` | Finishing up and, for production, making it the live deployment. |

A production deployment that stops being the live one also becomes `stopped`, after a 30-minute standby. See [Production and preview](/compute/concepts/production-and-preview).

The deployment's page shows each step with its start and end time as it runs. A deployment from a prebuilt image has no build log. See [Build logs](/compute/observe/build-logs).

## Why a deployment is waiting

A workspace runs one deployment at a time on every plan, from build through rollout. The next one sits in `pending` until the one ahead finishes. Deployments from a prebuilt image queue too, even though they don't build. Production deployments jump ahead of preview deployments, so a hot-fix doesn't wait behind a backlog of pull request builds.

A deployment that has waited an hour for its turn fails rather than waiting longer. Deploy again once the queue has drained. See [Builds](/compute/build/overview#why-your-build-is-waiting).

## Why a deployment was superseded or skipped

If you push a second commit to a branch while the first commit's deployment is still `pending` or `awaiting_approval`, the first one becomes `superseded` and the newer commit builds instead. Once a deployment has left `pending`, it finishes even if newer commits arrive.

`skipped` means we saw the push but didn't build it. Either auto deploy is off for the environment, or no changed file matched the environment's [watch paths](/compute/configure/build-settings). The deployments list shows the reason.

## Awaiting approval

A pull request from a fork runs outside code with your environment variables, so it never builds on its own. It waits in `awaiting_approval` until a project member approves it in the dashboard. GitHub shows a check named **Unkey Deploy Authorization** on the commit while it waits. Pushes from people with write access don't need approval. See [GitHub integration](/compute/build/github).

## Cancel a deployment

To cancel a deployment that's still in progress, open its menu in the deployments list and click **Cancel deployment**. The current step is marked "Cancelled by user", the deployment ends as `cancelled`, and the next deployment in the queue can start. A finished deployment can't be canceled.

## Rollouts across regions

During `deploying`, we wait for each region to reach its minimum number of instances. The deployment goes live once all regions but one are healthy, so one region that fails to start doesn't block the release. A single-region environment waits for that one region. If enough regions aren't healthy within 15 minutes, the deployment fails. See [Regions](/compute/concepts/regions).

## Why a deployment failed

The deployment's page shows the step that failed and a message. In the API, a `failed` deployment has an `error` object with a `code`, the `step` that failed, and a `message`. The codes are:

| Code | Meaning |
| --- | --- |
| `no_schedulable_regions` | The environment has no region that can currently be scheduled. Configure at least one region. |
| `invalid_runtime_settings` | The port is outside 1 to 65535, the CPU is below 0.25 vCPU, or the memory is below 256 MiB. |
| `cpu_quota_exceeded` | Starting this deployment would push the workspace past its CPU limit across all running deployments. |
| `memory_quota_exceeded` | The same for memory. |
| `storage_quota_exceeded` | The same for ephemeral disk. |
| `build_failed` | The image build did not complete. The build log names the failing step. |
| `unknown` | We couldn't classify the failure. Read `message`. |

For the quota codes, we add what your running deployments already use to this deployment's CPU, memory, and disk at its maximum instance count in every region, and compare that to your workspace [limits](/platform/billing/limits). So a wide autoscaling range counts at its maximum. Scale down or remove a deployment to make room.

## What you can do with a deployment

You can promote and roll back production deployments, and stop and start preview deployments. See [Production and preview](/compute/concepts/production-and-preview). The dashboard also offers **Redeploy** for any deployment that's `ready`, `stopped`, `failed`, `superseded`, or `cancelled`, and **Cancel deployment** for anything in progress.

In the API, a deployment's `availableActions` field lists the actions that will work right now, so you can check before you call.

## When the deployment isn't serving

What callers get depends on why:

- **The deployment is stopped:** `503` with [`deployment_offline`](/errors/frontline/capacity/deployment_offline).
- **It should be running, but no instance is up yet:** `503` with [`no_running_instances`](/errors/frontline/capacity/no_running_instances).
- **The workspace hit its Compute spend budget with the stop option on:** every deployment returns `402` with [`spend_limit_reached`](/errors/frontline/capacity/spend_limit_reached) until you raise or remove the budget.

---

### Compute Concepts Instances And Autoscaling (`docs/compute/concepts/instances-and-autoscaling.mdx`)

---
title: Instances and autoscaling
description: "What an instance is, how many you run, and when Unkey adds more."
sources:
  - svc/ctrl/services/app/create_app.go
  - svc/api/openapi/spec/common/EnvironmentRegion.yaml
  - svc/api/openapi/spec/common/Replicas.yaml
  - svc/api/openapi/spec/common/EnvironmentHealthcheck.yaml
  - svc/api/openapi/spec/paths/v2/environments/updateSettings/V2EnvironmentsUpdateSettingsRequestBody.yaml
  - svc/api/routes/v2_environments_update_settings/handler.go
  - svc/krane/internal/deployment/apply.go
  - svc/krane/internal/deployment/consts.go
  - svc/frontline/internal/router/routing.go
  - svc/frontline/routes/proxy/handler.go
  - web/internal/db/src/schema/limits.ts
  - web/apps/dashboard/lib/limits.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/compute/compute-fields.tsx
---

An instance is one running container of a <Tooltip tip="One built and running version of an app in one environment.">deployment</Tooltip> in one region. A deployment with two regions and two replicas each has four instances, all running the same image with the same variables. Instances are ephemeral: nothing written inside one survives its replacement.

## How many replicas you get

Each <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environment</Tooltip> sets, per region, a minimum and a maximum replica count. A new <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip> created with the API or CLI starts with one replica in its default region. In the dashboard, the **Instances** field on the environment's **Compute** card sets the bounds as **Min** and **Max**, and both start at 1. With minimum and maximum equal, the count is pinned and autoscaling is off. Autoscaling starts only when you raise the maximum above the minimum.

The minimum is at least 1, so you can't scale to zero. The maximum is capped by your plan's replicas-per-region limit: 4 on Starter, 8 on Pro, and 16 on Business. Every region uses the same minimum and maximum.

One replica gives no availability guarantee within a region. Two replicas let the deployment keep serving while one instance is replaced, and Unkey keeps at most one instance of a deployment unavailable at a time during planned disruption. For a regional outage you need a second region. See [Regions](/compute/concepts/regions).

## How autoscaling decides

When the maximum exceeds the minimum, Unkey watches average CPU across the deployment's instances in each region. It adds replicas when average CPU exceeds 80 percent of the requested CPU and removes them when load drops, waiting 60 seconds after a drop before scaling in so short spikes don't cause flapping.

## CPU, memory, and disk

[Runtime settings](/compute/configure/runtime-settings) give each instance a CPU allocation in vCPUs (minimum 0.25, in steps of 0.25), a memory allocation in MiB (minimum 256, in steps of 256), and an optional ephemeral disk in MiB (steps of 512). The upper bounds are your workspace's per-instance limits on [Limits](/platform/billing/limits), and the API returns `400` if you exceed them. The workspace-wide totals are checked when a deployment starts, as described under [Deployments](/compute/concepts/deployments).

The allocation you set is the hard limit for the container. Half of it is guaranteed, and an instance can burst to its full allocation when there's spare capacity. The container's own writable filesystem is capped at 128 MiB. That's scratch space for logs and small files, not storage. When you configure ephemeral disk, it's mounted at `/data` and `UNKEY_EPHEMERAL_DISK_PATH=/data` is set so your code can find it. The disk starts empty with each instance and is deleted when the instance stops.

## What the container sees

Unkey injects `PORT` with the configured port (default 8080) and a set of `UNKEY_*` variables naming the deployment, environment slug, region, instance, and the git commit, branch, repository, and commit message when the deployment came from git. Your environment variables are added alongside, and the injected variables win if the names collide. The container receives the configured shutdown signal (`SIGTERM` by default) when an instance is being replaced or scaled in.

## Health probes

A [health check](/compute/configure/health-checks) is optional. When you configure one, Unkey probes the container over HTTP at the path you give with `GET` or `POST`, every `intervalSeconds` (default 10, at most 3600), with a `timeoutSeconds` (default 5), after an `initialDelaySeconds` (default 0). The same probe drives two decisions: an instance that hasn't passed yet receives no traffic, and an instance that fails `failureThreshold` consecutive probes (default 3) is restarted. A `GET` probe counts any status from 200 to 399 as a pass.

Without a health check, an instance is treated as ready as soon as its container is running, and it's only restarted if the process exits. A probe that checks downstream dependencies can therefore take your whole deployment out when a dependency blips. Probe the process, not the database.

## How traffic reaches instances

The gateway in each region sends a request to a random running instance of the deployment in that region. If it can't connect to one, it tries another. An error after the request was sent is returned as-is rather than retried, so your handlers never run twice for one request. If no instance in the region is running, the gateway forwards the request to the nearest region that has one, and if none does, it answers `503` with [`no_running_instances`](/errors/frontline/capacity/no_running_instances).

---

### Compute Concepts Production And Preview (`docs/compute/concepts/production-and-preview.mdx`)

---
title: Production and preview
description: "What you can do with a deployment in production and in preview."
sources:
  - svc/api/openapi/spec/common/EnvironmentKind.yaml
  - svc/api/openapi/spec/common/Deployment.yaml
  - svc/api/openapi/spec/common/DeploymentAction.yaml
  - svc/api/openapi/spec/paths/v2/deployments/promoteDeployment/V2DeploymentsPromoteDeploymentRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/deployments/rollbackDeployment/V2DeploymentsRollbackDeploymentRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/deployments/stopDeployment/V2DeploymentsStopDeploymentRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/deployments/startDeployment/V2DeploymentsStartDeploymentRequestBody.yaml
  - svc/api/internal/deployment/actions.go
  - pkg/deploy/deploygate/deploygate.go
  - svc/ctrl/worker/deploy/deploy_handler.go
  - svc/ctrl/worker/environment/promote_handler.go
  - svc/ctrl/worker/environment/rollback_handler.go
  - svc/ctrl/worker/deployment/stop_handler.go
  - svc/ctrl/worker/deployment/wake_handler.go
  - svc/ctrl/worker/cron/idlepreview/handler.go
  - svc/ctrl/worker/cron/cron.go
  - svc/ctrl/internal/db/github_repo_connection_list_deploy_context.sql_generated.go
  - web/internal/db/src/schema/apps.ts
  - web/internal/db/src/schema/frontline_routes.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/deployments/components/table/components/actions/deployment-action-eligibility.ts
---

Every <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip> has a production <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environment</Tooltip> and a preview environment. Production has one live <Tooltip tip="One built and running version of an app in one environment.">deployment</Tooltip> that you promote and roll back. Preview has many short-lived deployments that you can stop and start, and we stop them for you when they go quiet.

## Where a push lands

For a git-connected app, a push to the default branch creates a production deployment. A push to any other branch, or a pull request from a fork, creates a preview deployment. When you deploy from the API, CLI, or dashboard, you pick the environment.

## Production

### The live deployment

An app has one live production deployment. The environment domain, the shorter live domain, and any verified custom domains all point at it. When a new production deployment reaches `ready`, it goes live and those domains move to it, unless the app is rolled back (see [Rollback](#rollback)). The deployment it replaced keeps running for 30 minutes as a standby, then stops.

### Promote

**Promote** in the dashboard, or `deployments.promoteDeployment` in the API, makes a `ready` production deployment the live one. Use it to ship a deployment that was built while the app was rolled back, or to move traffic to an earlier deployment on purpose. The deployment it replaces stops after 30 minutes.

You can promote a deployment when it's `ready` and not stopped, and the app already has a live deployment. Promoting the deployment that's already live only works while the app is rolled back. That's a **confirm rollback**: nothing moves, but the rolled-back state ends and new pushes go live again.

### Rollback

**Rollback** in the dashboard, or `deployments.rollbackDeployment` in the API, moves traffic back to the deployment you pick. The domains move in one step and the app is marked as rolled back.

While an app is rolled back, new production deployments still build and reach `ready`, but they don't go live. So a push can't undo your rollback. To end the rolled-back state, promote the deployment you rolled back to (confirm rollback) or promote a newer one.

You can roll back to a deployment that's `ready`, not stopped, and not the live one. A stopped deployment, including an old live one after its 30 minute standby, can't be a rollback target. To get back to it, click **Redeploy** on it, wait for `ready`, and promote it if the app is rolled back. Or roll forward with a fixed commit.

You can't stop or start production deployments by hand, because stopping the live one would take your app offline.

## Preview

### Stop and start

**Stop deployment** in the dashboard, or `deployments.stopDeployment` in the API, stops a `ready` preview deployment and frees its resources. It shows `stopped` once no instance is left.

**Wake deployment** in the dashboard, or `deployments.startDeployment` in the API, starts a stopped preview deployment again. It goes through `deploying` and back to `ready`. You can't start one while the workspace is suspended by its Compute spend budget.

### Automatic stops

We stop deployments for you in these cases. A stopped preview deployment can be started again at any time.

| Trigger | What stops | When |
| --- | --- | --- |
| New live production deployment, or a promote | The previous live deployment | 30 minutes later |
| New preview deployment on a branch | The deployment that was on that branch domain | 1 minute later |
| Preview deployment gets no requests | That deployment | After 1 hour idle |
| You stop it | A `ready` preview deployment | Right away |

## What callers see during a switch

Promote and rollback don't restart anything. The domains move to the other deployment, and the old one keeps serving until they have. The switch reaches every region within a few seconds, so a few requests can still reach the old deployment right after. A request to a stopped deployment gets `503` with [`deployment_offline`](/errors/frontline/capacity/deployment_offline).

---

### Compute Concepts Projects Apps Environments (`docs/compute/concepts/projects-apps-environments.mdx`)

---
title: Projects, apps, and environments
description: "How projects, apps, and environments fit together."
sources:
  - svc/ctrl/services/project/create_project.go
  - svc/ctrl/services/app/create_app.go
  - pkg/deploy/projectgate/projectgate.go
  - svc/api/openapi/spec/common/Project.yaml
  - svc/api/openapi/spec/common/App.yaml
  - svc/api/openapi/spec/common/Environment.yaml
  - svc/api/openapi/spec/common/EnvironmentKind.yaml
  - svc/api/openapi/spec/paths/v2/apps/createApp/V2AppsCreateAppRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/apps/updateApp/V2AppsUpdateAppRequestBody.yaml
  - web/internal/db/src/schema/environments.ts
  - web/apps/dashboard/lib/navigation/leaves.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/page.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/new/page.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/settings/sections.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/settings/environments/[environment]/page.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/build-settings/build-settings.tsx
---

Your workspace holds projects. A project holds <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">apps</Tooltip>. Every app has exactly two <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environments</Tooltip>: production and preview. A <Tooltip tip="One built and running version of an app in one environment.">deployment</Tooltip> belongs to one environment. Almost everything you configure (environment variables, gateway policies, the container's CPU) is set per environment.

<Tree>
  <Tree.Folder name="acme (workspace)" defaultOpen>
    <Tree.Folder name="payments (project)" defaultOpen>
      <Tree.Folder name="api (app, connected to acme/payments on GitHub)" defaultOpen>
        <Tree.Folder name="production (environment)" defaultOpen>
          <Tree.File name="build and runtime settings" />
          <Tree.File name="environment variables" />
          <Tree.File name="custom domains" />
          <Tree.File name="gateway policies" />
          <Tree.File name="deployments, one of them current" />
        </Tree.Folder>
        <Tree.Folder name="preview (environment)">
          <Tree.File name="its own settings, variables, policies, and deployments" />
        </Tree.Folder>
      </Tree.Folder>
      <Tree.Folder name="worker (app, deployed from an image)">
        <Tree.File name="production and preview, same shape" />
      </Tree.Folder>
    </Tree.Folder>
  </Tree.Folder>
</Tree>

## Projects

A project is how you group apps. It has a name and a slug. The slug is what you pass to the API and the CLI, so it must be unique in your workspace. The slug `default` is reserved, so the API will return an error if you use it. A project can also carry delete protection, which blocks deletion until you turn it off. See [Delete protection](/platform/security/delete-protection).

Creating a project creates nothing else. There's no default app. In the dashboard, creating a project opens the new-app flow. The API returns the project alone. Unlike platforms where a project is itself what you deploy, here the project is only a folder.

In the dashboard, the project level holds things that span apps: runtime **Logs** and gateway **Requests** for every app in the project, plus **Project settings** (name and delete).

## Apps

An app is what you deploy. Two apps in one project build and deploy on their own. A monorepo with an API and a background worker is two apps on the same repository with different root directories. The project page lists apps. **Create app** adds another.

An app has a name, a slug unique within its project, and a source. The source is either a GitHub repository or a container image. The source kind is fixed when you create the app. For an image-sourced app, the dashboard's **Build** settings page shows only the image, because there's nothing to build.

For a git-connected app the tracked branch is `defaultBranch`, which defaults to the repository's default branch on GitHub. Pushes to that branch deploy to production. Every other branch deploys to preview. You can retarget the branch, replace the repository, or disconnect.

## Environments

Creating an app also creates its two environments: `production` with kind `production` and `preview` with kind `preview`. You can't add a third environment or delete either of these. The environment slug is what you pass to the API alongside the project and app slugs.

In the dashboard, **App settings** > **Environments** lists both environments. Each one opens a page with its compute (regions, size, instances, and storage), its domains, and, for a git-connected app, its tracked branch and auto-deploy switch.

The kind decides how deployments behave:

- `production` deployments serve the live domains, can be promoted and rolled back, and can't be stopped.
- `preview` deployments can be stopped and started and are stopped automatically when idle.

## Identifiers

Every Compute endpoint accepts either the ID or the slug for a project, app, or environment. A request looks like `project: payments, app: api, environment: production`, or uses the `proj_`, `app_`, and `env_` IDs the list endpoints return. The dashboard URL carries the IDs.

---

### Compute Concepts Regions (`docs/compute/concepts/regions.mdx`)

---
title: Regions
description: "Which regions you can run in, how to choose them, and what happens when one goes down."
sources:
  - web/internal/db/src/schema/regions.ts
  - web/internal/db/src/schema/app_regional_settings.ts
  - svc/ctrl/services/app/create_app.go
  - svc/api/openapi/spec/common/EnvironmentRegion.yaml
  - svc/api/openapi/spec/common/Replicas.yaml
  - svc/api/openapi/spec/paths/v2/environments/updateSettings/V2EnvironmentsUpdateSettingsRequestBody.yaml
  - svc/api/routes/v2_environments_update_settings/handler.go
  - svc/ctrl/worker/deploy/readiness.go
  - svc/frontline/internal/router/routing.go
  - web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/get-available-regions.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/compute/compute-fields.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/compute/status.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/settings/environments/[environment]/compute-overview.tsx
---

A region is a place Unkey runs your instances. Each <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environment</Tooltip> of an <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip> has its own region list, and every <Tooltip tip="One built and running version of an app in one environment.">deployment</Tooltip> in that environment runs in all of them.

## Available regions

Unkey runs on AWS and uses AWS region names. Compute runs in four:

| Region | Location |
| --- | --- |
| `us-west-2` | Oregon |
| `us-east-1` | Northern Virginia |
| `eu-central-1` | Frankfurt |
| `ap-southeast-1` | Singapore |

We add regions over time. A region can also be closed to new deployments for a while, and the dashboard grays it out. Picking one anyway fails with `Region 'us-east-1' is not available for scheduling.`, and a name that is not a region at all fails with `Region 'eu-west-1' does not exist.`

## Set the regions for an environment

Production and preview have separate lists, so you can run production in three regions and preview in one.

<Frame>
  <img src="/images/dashboard/compute--concepts-regions--regions.png" alt="Configure compute panel for the production environment with Frankfurt and N. Virginia selected under Regions, plus Size, Instances, and Storage" />
</Frame>

In the dashboard, open the app, go to **App settings** > **Environments**, and open the environment. Click **Configure** on the **Compute** card and pick regions under **Regions**. Unschedulable regions are visible but not selectable, and you can't remove the last region. From the CLI or the API:

<CodeGroup>
```bash CLI
unkey api environments update-settings \
  --project=payments --app=payments-api --environment=production \
  --regions='[{"name":"us-east-1","replicas":{"min":1,"max":2}},{"name":"eu-central-1","replicas":{"min":1,"max":2}}]'
```
```bash API
curl -X POST https://api.unkey.com/v2/environments.updateSettings \
  -H "Authorization: Bearer <root key>" \
  -H "Content-Type: application/json" \
  -d '{
    "project": "payments",
    "app": "payments-api",
    "environment": "production",
    "regions": [
      {"name": "us-east-1", "replicas": {"min": 1, "max": 2}},
      {"name": "eu-central-1", "replicas": {"min": 1, "max": 2}}
    ]
  }'
```
</CodeGroup>

The list you send replaces the whole set. A region you leave out is dropped, so send every region you want, not just the new one. The rules the API enforces:

- Between 1 and 5 regions. An empty list is rejected, because an environment cannot have zero regions.
- No duplicates: `Region 'us-east-1' is listed more than once.`
- Every region needs the same `min` and `max`. Mixed bounds fail with `All regions must specify the same replica bounds; per-region autoscaling is not supported yet.`
- `min` is at least 1 and `max` is at most your plan's replicas-per-region ceiling, which is on [Limits](/platform/billing/limits). Outside that you get `Region 'us-east-1' replicas must be between 1 and 4.`

## How failover works

Requests reach whichever Unkey gateway is nearest to the caller. That gateway then decides where to send the request:

1. If its own region has running instances of the deployment, it serves from there. This is the normal path and costs no extra hop.
2. If not, it forwards to the nearest region that does have one, following a fixed proximity order.
3. If none of the regions in that order has an instance, it forwards to any region that does.
4. If no region has one, the request fails with `503` and [`no_running_instances`](/errors/frontline/capacity/no_running_instances).

Only regions your environment is deployed to are candidates. Our gateway picks among the regions that have a running instance of that deployment, so a deployment that runs in one region never serves from another, and traffic never reaches a region you did not choose.

When your environment does span several of them, step 2 resolves in this order:

| Gateway region | Prefers, in order |
| --- | --- |
| `us-west-2` | `us-east-1`, `ap-southeast-1`, `eu-central-1` |
| `us-east-1` | `us-west-2`, `eu-central-1`, `ap-southeast-1` |
| `eu-central-1` | `us-east-1`, `us-west-2`, `ap-southeast-1` |
| `ap-southeast-1` | `us-west-2`, `us-east-1`, `eu-central-1` |

That ordering matters for data residency. `eu-central-1` is the only European region today, so an environment that must keep traffic in the EU has to run there alone, and it has no regional redundancy. If Frankfurt goes down the deployment returns `503` rather than serving from elsewhere. Adding a second region for availability means accepting that requests can be served outside the EU.

A second region is what protects you from losing a region. Extra replicas in one region protect you from losing an instance, but if that region goes away, so does your app.

Failover is per request and automatic. There is nothing to configure and no order you can set: adding a region to the environment is what makes it a target.

### Rollouts tolerate one bad region

When a deployment starts, Unkey waits for each region to reach the environment's minimum replica count, and releases once all but one region is healthy. A single-region environment waits for that region. A three-region environment goes live once two are up, and the third joins when it recovers. If enough regions are not healthy within 15 minutes, the deployment fails. See [Deployments](/compute/concepts/deployments).

## Choosing regions

Put a region near your users and a region near the data your app talks to. A request served far from your database pays that round trip on every call, which usually outweighs the latency you saved by being close to the user.

Two regions with a couple of replicas each is the minimum we advise for production. Preview environments rarely need more than one region, since they exist to be looked at rather than to survive an outage.

---

### Compute Configure Build Settings (`docs/compute/configure/build-settings.mdx`)

---
title: Build settings
description: "Every build setting an environment has, with defaults and validation rules."
sources:
  - svc/api/openapi/spec/paths/v2/environments/updateSettings/V2EnvironmentsUpdateSettingsRequestBody.yaml
  - svc/api/openapi/spec/common/EnvironmentBuild.yaml
  - svc/api/routes/v2_environments_update_settings/handler.go
  - svc/ctrl/services/app/create_app.go
  - svc/ctrl/worker/deploy/deploy_handler.go
  - svc/ctrl/worker/deploy/railpack.go
  - pkg/match/watchpath.go
  - web/internal/db/src/schema/app_build_settings.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/environment-provider.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/build-settings/build-settings.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/build-settings/dockerfile-settings.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/build-settings/root-directory-settings.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/build-settings/build-command-settings.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/build-settings/watch-paths-settings.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/settings/environments/[environment]/auto-deploy.tsx
---

Build settings control how a git-connected <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip> is built, and which pushes build at all. Each <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environment</Tooltip> has its own, so production and preview can differ. Apps deployed from an image have no build settings.

## Change build settings

Edit them under **App settings** > **Build** in the dashboard, with `environments.updateSettings` in the API, or with `unkey api environments update-settings` in the CLI. In the API, send only the fields you want to change. To read the current values, call `environments.getEnvironment` and look under `build`.

The **Build** page shows the production values, and saving there writes to every environment of the app. To give production and preview different build settings, use the API or CLI. **Auto-deploy** is the exception: each environment has its own switch, on its page under **App settings** > **Environments**.

<Frame>
  <img src="/images/dashboard/compute--configure-build-settings--build.png" alt="App settings Build page showing the repository, root directory, Dockerfile, build command, and watch paths" />
</Frame>

A change applies from the next deployment. Existing deployments keep the settings they were built with.

## Fields

<ParamField body="dockerfile" type="string | null">
  Path to the Dockerfile, relative to the root directory, 1 to 500 characters. When it's unset (the default), we detect your language and build without a Dockerfile. Send `null` to go back to automatic builds. In the dashboard, the **Dockerfile** row lists Dockerfiles in your repository and shows "File not found on this branch" if the path is missing. See [Dockerfile builds](/compute/build/dockerfile).
</ParamField>

<ParamField body="rootDirectory" type="string" default=".">
  The directory we build from, 1 to 500 characters. Automatic detection looks here, and a Dockerfile build uses it as the build context. Use `.` or a relative path like `services/api` with letters, digits, `.`, `_`, and `-`. A path that starts with `/` or `./`, or contains `..`, a backslash, or spaces, returns "Root directory must be a relative path like 'api' or 'services/api'." The dashboard suggests app directories it finds in your repository.
</ParamField>

<ParamField body="buildCommand" type="string | null">
  Replaces the build command automatic detection would run, 1 to 1000 characters, for example `pnpm --filter api build`. Ignored when a Dockerfile is set, and the dashboard marks the field "Not used with a Dockerfile." Send `null` to go back to the detected command.
</ParamField>

<ParamField body="watchPaths" type="string[]" default="[]">
  Glob patterns that decide whether a push builds. Up to 10 patterns, each up to 500 characters. When the list is empty, every push builds. Otherwise a push builds only if a changed file matches a pattern. `src/**` matches everything under `src`, and `**/*.go` matches every Go file. A pattern without a wildcard matches only that exact path, so `src` isn't the same as `src/**`. Don't start a pattern with `/` or `./`. An invalid pattern returns `400` when you save it. See [GitHub integration](/compute/build/github).
</ParamField>

<ParamField body="autoDeploy" type="boolean" default="true">
  Whether GitHub pushes create deployments in this environment. In the dashboard, it's the **Auto-deploy** switch under **Branch tracking** on the environment's page. When it's off, a push shows up as a `skipped` deployment with "Auto deploy is disabled for this environment." A common setup is auto deploy on for preview and off for production: every branch gets a preview, and you release to production on purpose from the dashboard, API, or CLI.
</ParamField>

## Example

```bash Set a Dockerfile and scope the build to a subdirectory
curl -X POST https://api.unkey.com/v2/environments.updateSettings \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "project": "payments",
    "app": "api",
    "environment": "production",
    "rootDirectory": "services/api",
    "dockerfile": "Dockerfile",
    "watchPaths": ["services/api/**", "packages/shared/**"]
  }'
```

## Next steps

<Columns cols={2}>
  <Card title="Builds" icon="hammer" href="/compute/build/overview">
    How builds run, why they wait or fail, and caching.
  </Card>
  <Card title="Runtime settings" icon="sliders" href="/compute/configure/runtime-settings">
    Port, CPU, memory, disk, command, protocol, regions.
  </Card>
</Columns>

---

### Compute Configure Environment Variables (`docs/compute/configure/environment-variables.mdx`)

---
title: Environment variables
description: "Give your app configuration and secrets that are encrypted and scoped per environment."
sources:
  - svc/api/openapi/spec/common/EnvironmentVariable.yaml
  - svc/api/openapi/spec/common/EnvironmentVariableKind.yaml
  - svc/api/openapi/spec/common/EnvironmentVariableInput.yaml
  - svc/api/openapi/spec/paths/v2/environments/setEnvironmentVariables/V2EnvironmentsSetEnvironmentVariablesRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/environments/removeEnvironmentVariables/V2EnvironmentsRemoveEnvironmentVariablesRequestBody.yaml
  - svc/api/routes/v2_environments_set_environment_variables/handler.go
  - svc/api/routes/v2_environments_list_environment_variables/handler.go
  - pkg/validation/env_var.go
  - svc/ctrl/worker/deploy/env_file.go
  - svc/krane/internal/deployment/apply.go
  - svc/krane/internal/deployment/secrets.go
  - web/internal/db/src/schema/app_environment_variables.ts
  - web/apps/dashboard/lib/schemas/env-var.ts
  - web/apps/dashboard/lib/trpc/routers/deploy/env-vars/make-sensitive.ts
  - web/apps/dashboard/lib/trpc/routers/deploy/env-vars/rename.ts
  - web/apps/dashboard/lib/collections/deploy/env-vars.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/env-vars/env-file.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/env-vars/variables-table/variables-table.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/env-vars/variables-table/variable-rows.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/env-vars/add/add-env-vars-fields.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/env-vars/add/add-env-vars-button.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/new/panes/config-blocks.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/env-vars/list/env-var-edit-row.tsx
---

Environment variables give your <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip> configuration and secrets, both while it builds and while it runs. Each variable belongs to one <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environment</Tooltip>, so production and preview can have different values for `DATABASE_URL`. Every value is encrypted at rest.

## Add variables in the dashboard

1. Open the app and go to **Environment variables**.
2. Click **Add environment variable**. A panel opens with a table of keys and values.
3. Enter one or more key-value pairs. To import a `.env` file, drop it on the table, click **Import .env**, or paste its contents into a key cell.
4. Turn on **Sensitive** in a row for secrets.
5. Pick the target **Environment**, or keep **All environments** to set the same value everywhere.
6. Save, then redeploy. See [When a change takes effect](#when-a-change-takes-effect).

<Frame>
  <img src="/images/dashboard/compute--configure-environment-variables--add.png" alt="Add environment variables panel with a LOG_LEVEL row and a sensitive STRIPE_SECRET_KEY row, the Add variable and Import .env actions, the Environment select set to All environments, and Cancel and Save buttons" />
</Frame>

The `.env` import skips comments, strips surrounding quotes, and handles multi-line values such as PEM keys. If a key appears twice in the file, the last one wins. You can save up to 200 variables at once. If a key already exists in the target environment, nothing is saved, the row shows "A variable with this key already exists.", and the panel stays open. If you close the panel with unsaved rows, the dashboard asks before it discards them.

The saved list has one row for each variable in each environment. You can filter it by environment, search it by key, and sort it by name or newest. Click a row to edit its key, value, note, or sensitive setting.

### While you set up a new app

In the new-app flow, you can also change the value of a saved variable or delete it. Sensitive rows and rows marked "Varies by environment" can only be deleted. Changes apply to every selected environment.

<Frame>
  <img src="/images/dashboard/compute--configure-environment-variables--new-app.png" alt="Environment variables panel in the new-app flow with saved DATABASE_URL and LOG_LEVEL values, a masked sensitive STRIPE_SECRET_KEY, an empty row for a new variable, and the Environment select set to All environments" />
</Frame>

## Recoverable or write-only

Every variable is either `recoverable` or `writeonly`. Both are encrypted the same way. The difference is whether you can ever see the value again:

- **`recoverable`**: shown in the dashboard and returned by `environments.listEnvironmentVariables`. Use it for configuration you want to check later.
- **`writeonly`**: never shown or returned. You can only replace or delete it. Use it for API keys and other credentials.

The dashboard's **Sensitive** switch makes a variable `writeonly`, with the note "Hidden in the dashboard and the API after you save. Unkey encrypts every value." In the API, `writeonly` is the default.

## Rename, mark sensitive, or delete

These actions are only in the dashboard:

- **Make Sensitive** turns selected `recoverable` variables into `writeonly`. This can't be undone.
- **Rename** changes the key in the edit form of a row. It changes the key in that row's environment only, and fails if the new name is already used there. It works for sensitive variables too.
- **Delete** removes a variable from the row's environment. Select several rows to delete them together.

## When a change takes effect

A deployment keeps the variables it was created with, for its build and its instances. Editing a variable doesn't change anything that's already running. To apply a change, push or redeploy. The dashboard shows a redeploy banner after an edit.

## Set variables with the API

`environments.setEnvironmentVariables` creates or overwrites the variables you send, in one atomic call: if any variable is invalid, nothing changes. Variables you don't send stay as they are.

An existing key is fully overwritten. If you leave out `description` or `kind`, they reset to empty and `writeonly`.

```bash Change one variable and leave the rest
curl -X POST https://api.unkey.com/v2/environments.setEnvironmentVariables \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "project": "payments",
    "app": "api",
    "environment": "production",
    "variables": [
      { "key": "LOG_LEVEL", "value": "info", "kind": "recoverable", "description": "Log verbosity" }
    ]
  }'
```

To replace the whole set, add `prune: true`. Every variable not in your list is deleted. `prune: true` with an empty list deletes them all.

`environments.removeEnvironmentVariables` deletes the keys you name, and ignores keys that don't exist. `environments.listEnvironmentVariables` returns each variable's `key`, `kind`, `createdAt`, and `description`, plus `value` for `recoverable` ones only.

## Limits

| Rule | Limit |
| --- | --- |
| Key | 1 to 256 characters matching `^[A-Za-z_][A-Za-z0-9_]*$`: letters, digits, and underscores, not starting with a digit. |
| Value | 1 to 16384 bytes of UTF-8. Multibyte characters count as more than one byte. Newlines inside the value are kept. The dashboard trims surrounding whitespace and rejects carriage returns. |
| Description | Up to 255 characters, optional. |
| Variables per API call | Up to 50 in one `setEnvironmentVariables` or `removeEnvironmentVariables` call. |
| Duplicate keys in one call | Rejected with `400`. |

There's no limit on how many variables an environment can have.

## Variables Unkey injects

At build time, your variables are available as described in [Build-time secrets](/compute/build/build-secrets). At run time, they're set as environment variables in each instance, along with these:

| Variable | Value |
| --- | --- |
| `PORT` | The environment's configured port. Your app must listen on it. |
| `UNKEY_DEPLOYMENT_ID` | The deployment's ID. |
| `UNKEY_ENVIRONMENT_SLUG` | `production` or `preview`. |
| `UNKEY_REGION` | The region the instance runs in. |
| `UNKEY_GIT_COMMIT_SHA` | The commit the deployment was built from. Empty for image deployments. |
| `UNKEY_GIT_BRANCH` | The branch of that commit. Empty for image deployments. |
| `UNKEY_GIT_REPO` | The repository as `owner/repo`. Empty for image deployments. |
| `UNKEY_GIT_COMMIT_MESSAGE` | The commit message. Empty for image deployments. |
| `UNKEY_INSTANCE_ID` | The instance's own ID. |
| `UNKEY_EPHEMERAL_DISK_PATH` | `/data`, only when the environment has ephemeral disk. |

If one of your variables has the same name as one of these, ours wins. `KUBERNETES_SERVICE_HOST` and the other `KUBERNETES_*` variables some libraries look for are set to empty strings.

## Next steps

<Columns cols={2}>
  <Card title="Build-time secrets" icon="key" href="/compute/build/build-secrets">
    Use variables during the build without baking them into layers.
  </Card>
  <Card title="Runtime settings" icon="sliders" href="/compute/configure/runtime-settings">
    The port and disk settings behind `PORT` and `UNKEY_EPHEMERAL_DISK_PATH`.
  </Card>
</Columns>

---

### Compute Configure Health Checks (`docs/compute/configure/health-checks.mdx`)

---
title: Health checks
description: "Tell Unkey how to probe your app and what happens when the probe fails."
sources:
  - svc/api/openapi/spec/common/EnvironmentHealthcheck.yaml
  - svc/api/openapi/spec/paths/v2/environments/updateSettings/V2EnvironmentsUpdateSettingsRequestBody.yaml
  - svc/api/routes/v2_environments_update_settings/handler.go
  - svc/krane/internal/deployment/apply.go
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/runtime-settings/healthcheck/schema.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/runtime-settings/healthcheck/index.tsx
  - web/apps/dashboard/lib/collections/deploy/environment-settings.ts
---

A health check tells us how to tell whether your <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip> is ready and still working. We send an HTTP request to each instance on a schedule. It's optional, and you set it per <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environment</Tooltip>.

## What the health check does

- **An instance gets no traffic until it passes.** So a deployment can wait for your app to finish starting before the first request arrives.
- **An instance that fails `failureThreshold` checks in a row is restarted.**

A `GET` check passes on any status from 200 to 399. Anything else, including a timeout, is a failure.

Without a health check, an instance is ready as soon as its container is running, and it's only restarted if the process exits. That's fine for apps that are ready as soon as they open the port. Add a check if startup takes a while, or if your process can be running but unable to serve.

## Choose what to check

Check the process, not its dependencies. A failing check restarts the container, so a check that queries your database takes every instance down when the database has a bad minute. `GET /healthz` returning `200` is enough.

## Add a health check

<Frame>
  <img src="/images/dashboard/compute--configure-runtime-settings--runtime.png" alt="App settings Runtime page with the Process group (port and start command) and the Health check group (endpoint and interval)" />
</Frame>

In the dashboard, go to **App settings** > **Runtime** and use the **Health check** group: pick `GET` or `POST`, enter the path under **Endpoint**, and set the **Interval**. Saving there applies the check to every environment of the app. In the API, set `healthcheck` in [runtime settings](/compute/configure/runtime-settings) with `environments.updateSettings`:

```bash Add a check with a shorter timeout
curl -X POST https://api.unkey.com/v2/environments.updateSettings \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "project": "payments",
    "app": "api",
    "environment": "production",
    "healthcheck": {
      "method": "GET",
      "path": "/healthz",
      "intervalSeconds": 10,
      "timeoutSeconds": 3,
      "failureThreshold": 3,
      "initialDelaySeconds": 5
    }
  }'
```

Like every runtime setting, it applies from the next deployment. To remove the check, clear the **Endpoint** path in the dashboard, or send `"healthcheck": null`.

## Fields

Only `method` and `path` are required. The others use the defaults below.

<ParamField body="method" type="GET | POST" required>
  The HTTP method. `POST` sends an empty body using `wget` inside your container, so your image must include `wget`.
</ParamField>

<ParamField body="path" type="string" required>
  The path to request on your app's port, 1 to 512 characters, starting with `/` and matching `^(/[\w\-]+)+(\.[\w]+)?$`, for example `/healthz`.
</ParamField>

<ParamField body="intervalSeconds" type="integer" default="10">
  Seconds between checks, 1 to 3600. The dashboard's **Interval** field takes whole seconds and starts at 10.
</ParamField>

<ParamField body="timeoutSeconds" type="integer" default="5">
  Seconds to wait for a response before the check fails, 1 to 3600. Only settable in the API. Saving from the dashboard keeps the current value, or sets 5 for a new check.
</ParamField>

<ParamField body="failureThreshold" type="integer" default="3">
  Failures in a row before the instance is restarted, 1 to 100. Only settable in the API. Saving from the dashboard keeps the current value, or sets 3 for a new check.
</ParamField>

<ParamField body="initialDelaySeconds" type="integer" default="0">
  Seconds after the container starts before the first check, 0 to 3600. Only settable in the API. Saving from the dashboard keeps the current value, or sets 0 for a new check.
</ParamField>

## Next steps

<Columns cols={2}>
  <Card title="Instances and autoscaling" icon="server" href="/compute/concepts/instances-and-autoscaling">
    How readiness, restarts, and traffic distribution fit together.
  </Card>
  <Card title="Deployments" icon="rocket" href="/compute/concepts/deployments">
    How a deployment waits for healthy instances across regions.
  </Card>
</Columns>

---

### Compute Configure Limits (`docs/compute/configure/limits.mdx`)

---
title: Compute limits
sidebarTitle: "Limits"
description: "The resource limits on your apps, per plan, and what happens when you hit one."
sources:
  - web/internal/db/src/schema/limits.ts
  - web/apps/dashboard/lib/limits.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/settings/limits/limit-groups.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/settings/limits/breach-banner.tsx
  - svc/api/routes/v2_environments_update_settings/handler.go
  - svc/api/routes/v2_environments_set_environment_variables/handler.go
  - svc/api/openapi/spec/paths/v2/environments/updateSettings/V2EnvironmentsUpdateSettingsRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/gateway/setPolicies/V2GatewaySetPoliciesRequestBody.yaml
  - svc/ctrl/worker/deploy/deploy_handler.go
  - svc/ctrl/services/customdomain/service.go
  - pkg/deploy/deployfail/messages.go
  - svc/ctrl/worker/deploy/build_handler.go
  - svc/ctrl/worker/cron/buildlimitsync/handler.go
  - svc/api/routes/v2_workspace_get_limits/handler.go
---

Your Compute plan sets how much CPU, memory, and disk your <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">apps</Tooltip> can use. Here's each limit, when we check it, and what you see when you hit it. The full workspace table, including API Management, is on [Limits](/platform/billing/limits).

**Settings > Limits** shows these limits with your current usage once you have a Compute plan. A limit you've reached shows the banner "You've reached a limit". To read the same limits and usage from code, call `workspace.getLimits` with a root key that has `unkey:v1:{workspace_id}:limits#read`.

<Frame>
  <img src="/images/dashboard/compute--configure-limits--limits.png" alt="Limits settings page showing API management, logs, and Compute limits with current usage bars" />
</Frame>

## Limits per plan

| Limit | Starter | Pro | Business | Checked |
| --- | --- | --- | --- | --- |
| CPU per instance | 2 vCPU | 8 vCPU | 16 vCPU | When you save runtime settings |
| Memory per instance | 2 GiB | 8 GiB | 32 GiB | When you save runtime settings |
| Ephemeral disk per instance | 10 GiB | 10 GiB | 10 GiB | When you save runtime settings |
| Workspace CPU | 30 vCPU | 120 vCPU | 240 vCPU | When a deployment starts |
| Workspace memory | 60 GiB | 240 GiB | 480 GiB | When a deployment starts |
| Workspace ephemeral disk | 120 GiB | 480 GiB | 960 GiB | When a deployment starts |
| Replicas per region | 4 | 8 | 16 | When you save regions |
| Concurrent builds | 1 | 1 | 1 | While deployments are in flight |
| Custom domains per workspace | 1 | Unlimited | Unlimited | When you add a domain |
| Log query range | 3 days | 7 days | 14 days | On every analytics API query |

Without a Compute plan you can't deploy. See [Compute plans](/compute/get-started/plans). "Unlimited" custom domains has a very high cap to stop abuse. Need a higher limit on your plan? Ask [support@unkey.com](mailto:support@unkey.com).

## Saving a size over your plan's limit

CPU, memory, and disk in [runtime settings](/compute/configure/runtime-settings) are checked when you save them. Go over and you get `400` with a message like "Memory per instance cannot exceed 2048 MiB. Contact support@unkey.com to increase it." The dashboard only offers sizes your plan allows, so you'll only see this error from the API or CLI.

## A deployment fails with a quota error

Workspace CPU, memory, and disk are checked when a deployment starts `deploying`, because they depend on what's already running. We add up what all your running instances use, plus this deployment's CPU, memory, and disk at its **maximum** replicas in each region. If the total is over a limit, the deployment fails with `cpu_quota_exceeded`, `memory_quota_exceeded`, or `storage_quota_exceeded` and the message "We are unable to deploy this application as you have exceeded your CPU quota" (or Memory, or Storage).

Because the check uses maximum replicas, an environment with a wide autoscaling range counts at its maximum even while it runs at the minimum.

To make room, lower an <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environment</Tooltip>'s maximum replicas or stop preview deployments. Replaced production deployments also stop on their own after 30 minutes, and idle previews after an hour.

## Replicas per region

Each region's `max` replicas can't go above your plan's limit, and `min` is at least 1. Go over and you get `400` with, on Starter, "Region 'x' replicas must be between 1 and 4." On every plan, all regions in an environment must use the same bounds, and an environment can have at most 5 regions.

## Deployments wait their turn

Every plan runs one deployment at a time per workspace. Others wait in `pending` for up to an hour, production ahead of preview, and fail if their turn doesn't come. See [Builds](/compute/build/overview).

## Custom domain limit reached

Adding a domain over your limit fails with `err:unkey:limits:custom_domain_limit_exceeded`. The dashboard shows "Custom domain limit reached" with links to your limits and to upgrade. Removing a domain frees up room.

## Log query range

The log query range limits how far back an analytics API query can reach. It's separate from how long we keep the data, and you can read whichever is shorter:

- Request log rows are stored for 7 days, so a Business workspace reads at most 7 days of them even though its query range allows 14.
- Runtime logs are stored for about 90 days, so a Starter workspace reads at most 3 days of them even though the rows exist for far longer.

A query that reaches further back than your plan allows fails with [`err:user:bad_request:query_range_exceeds_retention`](/errors/user/bad_request/query_range_exceeds_retention) instead of returning partial results. This only applies to the [analytics API](/compute/observe/analytics-api), not the dashboard's log views. See [Observability](/compute/observe/overview) for how long data is kept.

## Limits that are the same on every plan

| What | Limit |
| --- | --- |
| Regions per environment | 5 |
| Watch paths | 10 patterns of up to 500 characters |
| Build command | 1000 characters |
| Container command | 10 arguments of up to 4096 characters |
| Environment variables per request | 50; key up to 256 characters, value up to 16384 bytes |
| Gateway policies per environment | 50, with up to 10 match expressions each |

## What has no limit

There's no limit on the number of projects, apps, or deployments in a workspace, or on the number of environment variables in an environment. Every app has exactly two environments. Compute is billed by what runs, so the limits are on CPU, memory, and disk, not on how many things you create.

## Next steps

<Columns cols={2}>
  <Card title="Limits" icon="gauge-high" href="/platform/billing/limits">
    The full workspace table, including API Management and log rows.
  </Card>
  <Card title="Spend budget" icon="wallet" href="/compute/configure/spend-budget">
    Cap monthly Compute spend instead of resources.
  </Card>
</Columns>

---

### Compute Configure Runtime Settings (`docs/compute/configure/runtime-settings.mdx`)

---
title: Runtime settings
description: "Every runtime setting an environment has, with defaults, steps, and plan caps."
sources:
  - svc/api/openapi/spec/paths/v2/environments/updateSettings/V2EnvironmentsUpdateSettingsRequestBody.yaml
  - svc/api/openapi/spec/common/EnvironmentRuntime.yaml
  - svc/api/openapi/spec/common/EnvironmentRegion.yaml
  - svc/api/openapi/spec/common/Replicas.yaml
  - svc/api/openapi/spec/common/EnvironmentShutdownSignal.yaml
  - svc/api/openapi/spec/common/EnvironmentUpstreamProtocol.yaml
  - svc/api/routes/v2_environments_update_settings/handler.go
  - svc/ctrl/services/app/create_app.go
  - svc/ctrl/worker/openapi/scrape_handler.go
  - svc/krane/internal/deployment/apply.go
  - svc/krane/internal/deployment/consts.go
  - svc/frontline/internal/proxy/transport.go
  - pkg/deploy/deployfail/messages.go
  - web/apps/dashboard/lib/limits.ts
  - web/apps/dashboard/lib/compute/sizing.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/environment-provider.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/compute/compute-fields.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/runtime-settings/port-settings.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/runtime-settings/command.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/advanced-settings/openapi-spec-path.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/advanced-settings/upstream-protocol.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/settings/environments/[environment]/compute-overview.tsx
---

Runtime settings control how big your container is, how it starts and stops, and where it runs. Each <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environment</Tooltip> of an <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip> has its own.

## Change runtime settings

Edit them under **App settings** in the dashboard, with `environments.updateSettings` in the API, or with `unkey api environments update-settings` in the CLI. In the API, send only the fields you want to change. `environments.getEnvironment` returns the current values under `runtime` and `regions`.

In the dashboard, the settings are in three places:

- **Runtime** holds the port, start command, and health check.
- **Advanced** holds the OpenAPI spec path and upstream protocol.
- **Environments** opens a page for each environment. Click **Configure** on its **Compute** card to set regions, size (CPU and memory), instances, and storage.

**Runtime** and **Advanced** show the production values, and saving there writes to every environment of the app. Compute is saved for one environment only. To give production and preview a different port, command, or protocol, use the API or CLI.

<Frame>
  <img src="/images/dashboard/compute--configure-runtime-settings--runtime.png" alt="App settings Runtime page with the Process group (port and start command) and the Health check group (endpoint and interval)" />
</Frame>

A change applies from the next deployment, and the dashboard shows "Changes apply to the next deployment" on a group with unsaved edits. Running deployments keep what they started with, so to apply a change without new code, redeploy the current deployment.

Your plan caps CPU, memory, and disk per instance. Going over returns `400` with a message like "CPU per instance cannot exceed 2 vCPU. Contact support@unkey.com to increase it." See [Compute limits](/compute/configure/limits).

## Container

<ParamField body="port" type="integer" default="8080">
  The port your container listens on, 1 to 65535. We set it as the `PORT` environment variable, overriding any value in the image, and send traffic and health checks to it. A port outside the range fails the deployment with `invalid_runtime_settings`.
</ParamField>

<ParamField body="vCpus" type="number" default="0.25">
  CPU per instance in vCPUs, at least 0.25, in steps of 0.25, up to your plan's limit. An instance can use up to this much and is always guaranteed at least half. In the dashboard, set it with **Size** on the environment's **Compute** card. The presets are XS (1/4 vCPU, 256 MiB), S (1/2 vCPU, 1 GiB), M (1 vCPU, 2 GiB), L (2 vCPU, 4 GiB), XL (4 vCPU, 8 GiB), 2XL (8 vCPU, 16 GiB), and 4XL (16 vCPU, 32 GiB). A preset above your plan shows a lock and the plan that unlocks it. **Custom** sets CPU and memory on their own, in the same steps, up to what your plan allows.
</ParamField>

<ParamField body="memoryMib" type="integer" default="256">
  Memory per instance in MiB, at least 256, in steps of 256, up to your plan's limit. It's a hard limit: an instance that goes over is killed and restarted, and the dashboard shows `OOMKilled` with exit code 137. In the dashboard, it's part of **Size** on the environment's **Compute** card.
</ParamField>

<ParamField body="storageMib" type="integer" default="0">
  Temporary disk per instance in MiB, in steps of 512, up to your plan's limit. `0` means none. When set, each instance gets its own disk at `/data`, and `UNKEY_EPHEMERAL_DISK_PATH` is set to `/data`. Nothing on it survives a restart or a new deployment. Without it, the container can write at most 128 MiB. The dashboard's **Storage** field offers None, 512 MiB, and 1, 2, 5, 10, 20, and 50 GiB, up to what your plan allows. A value set through the API that isn't a preset shows as an extra option.
</ParamField>

<ParamField body="command" type="string[]" default="[]">
  Replaces the image's command, as an array of up to 10 strings of up to 4096 characters each. An empty array runs the image's own command. The dashboard splits a single line on spaces and ignores quotes, so use the API when an argument contains spaces.
</ParamField>

<ParamField body="shutdownSignal" type="SIGTERM | SIGINT | SIGQUIT | SIGKILL" default="SIGTERM">
  The signal your container's main process gets when an instance is replaced or scaled in. Set it with the API or CLI. It isn't in the dashboard.
</ParamField>

<ParamField body="upstreamProtocol" type="http1 | h2c" default="http1">
  How the gateway talks to your container. `http1` is HTTP/1.1 and works with every framework. `h2c` is HTTP/2 without TLS. Choose it only if your server supports h2c.
</ParamField>

<ParamField body="openapiSpecPath" type="string | null">
  The path where your app serves its OpenAPI document, for example `/openapi.yaml`. 1 to 512 characters, starting with `/` and matching `^(/[\w\-]+)+(\.[\w]+)?$`. When a deployment is `ready`, we fetch the document with a `GET` and save it with that deployment. Documents up to 10 MiB are accepted, and a `404` means the deployment has no spec. The spec is used by the gateway's OpenAPI validation policy and the dashboard's spec diff. Send `null`, or leave the dashboard field empty, to turn this off.
</ParamField>

<ParamField body="healthcheck" type="object | null">
  An HTTP check we run against each instance. See [Health checks](/compute/configure/health-checks). Send `null` to remove it.
</ParamField>

## Regions and replicas

<ParamField body="regions" type="EnvironmentRegion[]">
  The regions the environment runs in, 1 to 5 entries, each like `{ "name": "us-east-1", "replicas": { "min": 1, "max": 3 } }`. The list replaces the whole set, so regions you leave out are removed. In the dashboard, the **Regions** and **Instances** fields on the environment's **Compute** card edit this field. When you change **Instances** or add a region, the dashboard writes the same `min` and `max` to every region.

  - The region must be available, or you get "Region 'x' is not available for scheduling."
  - Each region can appear once.
  - `min` is at least 1. `max` is at least `min` and at most your plan's replicas-per-region limit.
  - Every region must use the same bounds, or you get "All regions must specify the same replica bounds; per-region autoscaling is not supported yet."

  A new app created with the API starts in one schedulable region, with `min` and `max` both 1, so autoscaling is off until you raise `max`. In the dashboard, a new app starts in every region available to the workspace. Pick your regions before you go live. See [Regions](/compute/concepts/regions).
</ParamField>

## Raise resources and add a region

```bash Raise resources and add a second region
curl -X POST https://api.unkey.com/v2/environments.updateSettings \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "project": "payments",
    "app": "api",
    "environment": "production",
    "vCpus": 1,
    "memoryMib": 1024,
    "regions": [
      { "name": "us-east-1", "replicas": { "min": 2, "max": 4 } },
      { "name": "eu-central-1", "replicas": { "min": 2, "max": 4 } }
    ]
  }'
```

The **Regions** field on the environment's **Compute** card lists the regions you can use. The change applies from the next deployment.

## Next steps

<Columns cols={2}>
  <Card title="Health checks" icon="heart-pulse" href="/compute/configure/health-checks">
    Probe fields, defaults, and what a failing probe does.
  </Card>
  <Card title="Instances and autoscaling" icon="server" href="/compute/concepts/instances-and-autoscaling">
    What the allocation and replica bounds mean at runtime.
  </Card>
  <Card title="Compute limits" icon="gauge-high" href="/compute/configure/limits">
    The per-instance and workspace ceilings behind the `400`s.
  </Card>
  <Card title="Regions" icon="earth-americas" href="/compute/concepts/regions">
    How regions are discovered, chosen, and used for failover.
  </Card>
</Columns>

---

### Compute Configure Spend Budget (`docs/compute/configure/spend-budget.mdx`)

---
title: Spend budget
description: "Cap what Compute can cost you in a month and choose whether it stops at the cap."
sources:
  - web/internal/db/src/schema/workspace_billing.ts
  - web/apps/dashboard/lib/trpc/routers/billing/deploy-budget/index.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/spend-budget-v2.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/settings/billing/components/cost-control.tsx
  - svc/ctrl/worker/cron/deployspendcheck/doc.go
  - svc/ctrl/worker/cron/deployspendcheck/thresholds.go
  - svc/ctrl/worker/cron/deployspendcheck/check.go
  - svc/ctrl/worker/deployteardown/doc.go
  - svc/ctrl/worker/deployteardown/resume.go
  - svc/frontline/internal/router/service.go
  - pkg/deploy/deploygate/deploygate.go
---

import { ProductLink } from "/snippets/product-link.jsx";

A spend budget caps what Compute usage can cost you in a month. You get emails as you approach it, and you can choose to stop everything when you reach it. Set it under **Settings > Billing**. Only workspace admins can change it. See [Plans](/platform/billing/plans) for how Compute billing works.

## Set a budget

Enter a whole dollar amount up to $10,000,000, or leave the field empty for no budget. The budget covers usage for the current calendar month across all five Compute meters: CPU, memory, disk, egress, and active keys. It counts usage before any included credit is subtracted, and doesn't include the plan fee.

With a budget set, we email workspace admins the first time spend crosses 50, 75, and 100 percent of it, once per threshold per month. If you raise the budget, the thresholds move with it. Alerts already sent aren't sent again.

<Frame>
  <img src="/images/dashboard/platform--billing-plans--billing.png" alt="Billing settings page showing the Compute and API management plans with Change and Upgrade buttons, and the Compute cost control budget with its email alerts" />
</Frame>

## Stop when you hit the budget

**Stop workloads at the budget** is off by default, and you need a budget before you can turn it on. When it's on and spend reaches 100 percent, we suspend Compute for the rest of the month:

- Every running deployment is stopped. We remember which one was live for each app so we can bring it back.
- Every request to the workspace's domains gets `402` with `spend_limit_reached`.
- You can't start, wake, or create deployments.
- The dashboard shows the workspace as paused by its spend cap.

This isn't cancellation. Your plan, settings, environment variables, domains, and deployment history stay. Nothing is billed while nothing runs.

<ProductLink product="platform" href="/errors/frontline/capacity/spend_limit_reached" title="err:frontline:capacity:spend_limit_reached">
  The 402 response body callers receive while a workspace is suspended, and how to tell it apart from a stopped deployment.
</ProductLink>

## Resume compute

Do one of these, and each app's live deployment starts again. You don't need to redeploy.

- **Remove the budget or turn off Stop workloads at the budget.** Compute resumes at the next check.
- **Raise the budget.** Compute resumes once spend has been under the new budget for two checks in a row.
- **Wait for the next month.** Compute resumes on its own when the new billing month starts.

If someone promoted a different deployment while Compute was suspended, we don't wake the old one.

## Budget vs limits

The budget is about money. [Compute limits](/compute/configure/limits) are about resources: they stop a deployment from using more CPU, memory, or disk than your plan allows, before it runs. The budget acts on what you've actually used this month. Use the budget to control cost, and limits to control size.

## Next steps

<Columns cols={2}>
  <Card title="Compute plans" icon="credit-card" href="/compute/get-started/plans">
    What the plan includes and what is metered above it.
  </Card>
  <Card title="Usage" icon="chart-line" href="/platform/billing/usage">
    What your workspace consumed this month.
  </Card>
</Columns>

---

### Compute Gateway Api Key Auth (`docs/compute/gateway/api-key-auth.mdx`)

---
title: API key authentication policy
sidebarTitle: "API key auth policy"
description: "Have the gateway verify Unkey API keys before requests reach your app."
sources:
  - svc/api/openapi/spec/common/KeyauthPolicy.yaml
  - svc/api/openapi/spec/common/KeyLocation.yaml
  - svc/api/openapi/spec/common/BearerTokenLocation.yaml
  - svc/api/openapi/spec/common/HeaderKeyLocation.yaml
  - svc/api/openapi/spec/common/QueryParamKeyLocation.yaml
  - svc/api/openapi/spec/common/KeyRatelimit.yaml
  - svc/api/routes/v2_gateway_set_policies/handler.go
  - svc/frontline/internal/policies/keyauth/executor.go
  - svc/frontline/internal/policies/keyauth/keyextract.go
  - svc/frontline/internal/policies/keyauth/headers.go
  - svc/frontline/internal/policies/engine.go
  - svc/frontline/internal/policies/redaction.go
  - svc/frontline/middleware/observability.go
  - internal/services/keys/status.go
---

import { ProductLink } from "/snippets/product-link.jsx";

<ProductLink product="api-management" href="/api-management/keys/creating-keys" title="Keyspaces and keys">
  This policy checks keys from an API Management [keyspace](/api-management/keyspaces/overview). Create the keyspace and its keys there first. The API Management docs explain [permission queries](/api-management/authorization/permission-queries), per-key rate limits, credits, and identities.
</ProductLink>

Have the [gateway](/compute/gateway/overview) <Tooltip tip="Key verification: the same checks keys.verifyKey runs, performed by the gateway. Not domain verification.">verify</Tooltip> an Unkey API key before a request reaches your <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip>, so your code doesn't have to. If the key is good, your app gets the caller's details in the [principal header](/compute/gateway/principal). If not, the caller gets a `401`, `403`, or `429` and your app never sees the request.

A key behaves the same as it does with `keys.verifyKey`.

To add one, open the app, go to **Policies**, click **Add policy**, and set **Type** to **Key Auth**. Or use the API or CLI as described in [Gateway policies](/compute/gateway/policies).

## Settings

<ParamField body="keyspaces" type="string[]" required>
  1 to 5 keyspace IDs. A key from any other keyspace is rejected as invalid. Each keyspace must be in the same workspace as the <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environment</Tooltip>, or saving fails with [`err:unkey:data:key_space_not_found`](/errors/unkey/data/key_space_not_found).
</ParamField>

<ParamField body="locations" type="KeyLocation[]" default="[{ bearer: {} }]">
  Where to look for the key, tried in order until one has a value. Each entry sets exactly one of:

  - `bearer: {}`: the `Authorization: Bearer <key>` header.
  - `header: { name, stripPrefix? }`: a custom header. If you set `stripPrefix` and the value doesn't start with it, the header is ignored.
  - `queryParam: { name }`: a query parameter.

  When you leave it out, only `bearer` is checked.
</ParamField>

<ParamField body="permissionQuery" type="string">
  Permissions the key must have, up to 1000 characters, for example `documents.read AND documents.write`. It uses the same syntax as `keys.verifyKey`. A query that can't be parsed rejects every request with `422`.
</ParamField>

<ParamField body="ratelimits" type="KeyRatelimit[]">
  Up to 10 per-key rate limits, the same as the `ratelimits` parameter of `keys.verifyKey`. Each has a `name` (a limit on the key or its identity, or a new name), an optional `limit` (requests) and `duration` (milliseconds) that go together, and a `cost` (default 1). If you name a limit the key doesn't have and don't give a `limit` and `duration`, the request gets a `500`.
</ParamField>

<ParamField body="credits" type="integer" default="1">
  Credits each matching request takes from the key. `0` checks the key without spending credits, which is useful for read-only routes. Keys with unlimited credits aren't affected.
</ParamField>

```json Example: header key with a prefix, read permission, no credit spend
{
  "name": "Key auth for the public API",
  "enabled": true,
  "match": [{ "path": { "path": { "prefix": "/v1/" } } }],
  "keyauth": {
    "keyspaces": ["ks_1234abcd"],
    "locations": [
      { "header": { "name": "X-API-Key" } },
      { "bearer": {} }
    ],
    "permissionQuery": "api.read",
    "credits": 0
  }
}
```

<Visibility for="agents">
  In the dashboard this is the **Key Auth** type in **Policies > Add policy**. Programmatically, include the object above in the `policies` array of `POST /v2/gateway.setPolicies`, or send it as `keyauth` in `POST /v2/gateway.updatePolicy` to replace an existing policy's rule. The change applies to the next deployment.
</Visibility>

## What callers get back

Checks run in this order and stop at the first failure, so the order decides which error the caller gets:

1. No key found in any location: `401` [`missing_credentials`](/errors/frontline/client/missing_credentials).
2. The key doesn't exist, is disabled, has expired, or its workspace is disabled: `401` [`invalid_key`](/errors/frontline/client/invalid_key). The caller can't tell which.
3. The key is from a keyspace not in `keyspaces`: `401` `invalid_key`.
4. The key lacks the permissions: `403` [`insufficient_permissions`](/errors/frontline/client/insufficient_permissions).
5. A rate limit is used up: `429` [`rate_limited`](/errors/frontline/client/rate_limited).
6. The key has no credits left: `429` [`usage_exceeded`](/errors/frontline/client/usage_exceeded).

On success, the request reaches your app with `X-Unkey-Principal` set. If several API key policies match a request, only the first one that succeeds counts, and the rest are skipped.

Every check, passed or failed, shows up in API Management analytics next to your own `keys.verifyKey` calls.

## Rate limit headers

When a rate limit applies to the key, the response includes `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (Unix seconds), whether the request passed or not. If several limits apply, the headers show the strictest one. A `429` also has `Retry-After` in whole seconds, at least 1. A [rate limit policy](/compute/gateway/rate-limiting) uses the same headers and only replaces them if its result is stricter.

## Keys stay out of your logs

When a [logging policy](/compute/gateway/logging) saves headers or query data, we redact the `Authorization` header and every header or query parameter listed in any API key policy's `locations`, even turned-off ones. Keys never reach the request log.

## Next steps

<Columns cols={2}>
  <Card title="The principal header" icon="id-badge" href="/compute/gateway/principal">
    What your app receives after a key is verified.
  </Card>
  <Card title="Gateway errors" icon="triangle-exclamation" href="/compute/gateway/errors">
    Every verification outcome and the error it becomes.
  </Card>
</Columns>

---

### Compute Gateway Errors (`docs/compute/gateway/errors.mdx`)

---
title: Gateway errors
sidebarTitle: "Errors"
description: "What the gateway returns when it rejects or cannot serve a request."
sources:
  - svc/frontline/middleware/observability.go
  - pkg/codes/frontline.go
  - pkg/codes/codes.go
  - svc/frontline/internal/policies/keyauth/executor.go
  - svc/frontline/internal/policies/ratelimit/executor.go
  - svc/frontline/internal/policies/firewall/executor.go
  - svc/frontline/internal/policies/openapi/executor.go
  - svc/frontline/internal/router/lookup.go
  - svc/frontline/middleware/clickhouse_logging.go
  - internal/services/keys/status.go
---

import { ProductLink } from "/snippets/product-link.jsx";

When a policy rejects a request, or the [gateway](/compute/gateway/overview) can't reach your <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip>, the gateway responds with an error code and a request ID. Use this page to find out what a code means and who needs to fix it. Most codes look like `err:frontline:{category}:{specific}`, and each one has its own page.

## Response format

If the request's `Accept` header includes `application/json`, `application/*`, or `*/*` without `text/html`, the body is JSON:

```json
{
  "meta": { "requestId": "req_2gJbXhAr4" },
  "error": {
    "code": "err:frontline:client:rate_limited",
    "message": "Rate limit exceeded. Please try again later."
  }
}
```

Otherwise it returns an HTML page with the status, message, code, request ID, and a link to the code's page. So browsers see a page and API clients see JSON. This format is different from the one the Unkey API uses.

`meta.requestId` matches the `X-Unkey-Request-Id` response header. Quote it when you contact the API owner or Unkey support.

## The codes

Codes are grouped by who needs to act.

### Client: fix the request or the key

| Code | Status | Cause |
| --- | --- | --- |
| [`err:frontline:client:missing_credentials`](/errors/frontline/client/missing_credentials) | 401 | An [API key authentication policy](/compute/gateway/api-key-auth) found no key in any configured location. |
| [`err:frontline:client:invalid_key`](/errors/frontline/client/invalid_key) | 401 | The key does not exist, is disabled or expired, belongs to a keyspace the policy does not list, or its workspace is disabled. |
| [`err:frontline:client:insufficient_permissions`](/errors/frontline/client/insufficient_permissions) | 403 | The key failed the policy's `permissionQuery`. |
| [`err:frontline:client:firewall_denied`](/errors/frontline/client/firewall_denied) | 403 | A [firewall policy](/compute/gateway/firewall) matched the request. |
| [`err:frontline:client:rate_limited`](/errors/frontline/client/rate_limited) | 429 | A per-key limit or a [rate limit policy](/compute/gateway/rate-limiting) was exceeded, or the rate limit policy couldn't identify the caller. |
| [`err:frontline:client:usage_exceeded`](/errors/frontline/client/usage_exceeded) | 429 | The key has no credits left. |
| [`err:frontline:client:openapi_validation_failed`](/errors/frontline/client/openapi_validation_failed) | 400 | The request didn't match the deployment's OpenAPI spec under an [OpenAPI validation policy](/compute/gateway/openapi-validation). |

### Configuration and capacity: you own the API

| Code | Status | Cause |
| --- | --- | --- |
| [`err:frontline:config:invalid_configuration`](/errors/frontline/config/invalid_configuration) | 422 | A policy on the deployment is invalid: a `permissionQuery` can't be parsed, a `credits` value is negative, or the OpenAPI spec is invalid. Fix it and redeploy. |
| [`err:frontline:capacity:no_running_instances`](/errors/frontline/capacity/no_running_instances) | 503 | No instance in any region is running to take the request. |
| [`err:frontline:capacity:deployment_offline`](/errors/frontline/capacity/deployment_offline) | 503 | The deployment was stopped, or its project was cancelled. |
| [`err:frontline:capacity:spend_limit_reached`](/errors/frontline/capacity/spend_limit_reached) | 402 | Compute for the workspace is paused because it reached its [spend budget](/compute/configure/spend-budget). Raise or remove the budget to resume. |
| [`err:frontline:routing:config_not_found`](/errors/frontline/routing/config_not_found) | 404 | No deployment is routed for this hostname. |
| [`err:frontline:routing:deployment_not_found`](/errors/frontline/routing/deployment_not_found) | 404 | The deployment requested by ID doesn't exist. |

### Upstream: your app or its connection

| Code | Status | Cause |
| --- | --- | --- |
| [`err:frontline:upstream:bad_gateway`](/errors/frontline/upstream/bad_gateway) | 502 | The connection to your app failed, for example it was reset. |
| [`err:frontline:upstream:proxy_forward_failed`](/errors/frontline/upstream/proxy_forward_failed) | 502 | Forwarding to your app failed for another reason. |
| [`err:frontline:upstream:service_unavailable`](/errors/frontline/upstream/service_unavailable) | 503 | The gateway couldn't connect to your app: it refused the connection or couldn't be reached. |
| [`err:frontline:upstream:gateway_timeout`](/errors/frontline/upstream/gateway_timeout) | 504 | Your app took too long to accept the connection or to respond. |

### Platform: Unkey acts

| Code | Status | Cause |
| --- | --- | --- |
| [`err:frontline:platform:config_load_failed`](/errors/frontline/platform/config_load_failed) | 500 | The gateway couldn't load the routing for this request. |
| [`err:frontline:platform:deployment_selection_failed`](/errors/frontline/platform/deployment_selection_failed) | 500 | The gateway couldn't pick an instance. |
| [`err:frontline:platform:internal_server_error`](/errors/frontline/platform/internal_server_error) | 500 | Something unexpected failed, including rate limiting or key checks. |

### Codes that are not `err:frontline:*`

A few `err:user:*` codes can also come from the gateway. Their pages describe how the Unkey API uses them, which can differ. For a Compute app, the status and cause are the ones below.

| Code | Status | Cause |
| --- | --- | --- |
| [`err:user:bad_request:client_closed_request`](/errors/user/bad_request/client_closed_request) | 499 | The client disconnected before the response finished. |
| [`err:user:bad_request:request_timeout`](/errors/user/bad_request/request_timeout) | 504 | The request took longer than the gateway's 15 minute limit. (The linked page's `408` and shorter limit apply to the Unkey API, not to Compute.) |
| [`err:user:bad_request:request_body_unreadable`](/errors/user/bad_request/request_body_unreadable) | 400 | The gateway couldn't read the request body. |
| [`err:user:bad_request:request_body_too_large`](/errors/user/bad_request/request_body_too_large) | 413 | You shouldn't see this in front of a Compute app. The gateway doesn't limit request body size. |

Requests that fail before reaching your app don't appear in your request log.

## If you already use keys.verifyKey

An [API key authentication policy](/compute/gateway/api-key-auth) checks keys like `keys.verifyKey`. If you already handle that endpoint's `code` values, here's what each one becomes at the gateway:

| Verification `code` | Gateway response |
| --- | --- |
| `VALID` | Forwarded, with `X-Unkey-Principal` set. |
| `NOT_FOUND` | 401 `err:frontline:client:invalid_key` |
| `DISABLED` | 401 `err:frontline:client:invalid_key` |
| `EXPIRED` | 401 `err:frontline:client:invalid_key` |
| `FORBIDDEN` | 401 `err:frontline:client:invalid_key` |
| `INSUFFICIENT_PERMISSIONS` | 403 `err:frontline:client:insufficient_permissions` |
| `RATE_LIMITED` | 429 `err:frontline:client:rate_limited` |
| `USAGE_EXCEEDED` | 429 `err:frontline:client:usage_exceeded` |

A key from a keyspace the policy doesn't list returns `invalid_key`, and a request with no key returns `missing_credentials`.

<ProductLink product="api-management" href="/api-management/keys/verifying-keys" title="Verifying keys">
  The verification checks, their order, and the `code` values are documented with `keys.verifyKey` in API Management.
</ProductLink>

## Next steps

<Columns cols={2}>
  <Card title="Error catalog" icon="book" href="/errors/overview">
    All three error systems and both envelope shapes.
  </Card>
  <Card title="Gateway policies" icon="list-check" href="/compute/gateway/policies">
    Evaluation order determines which error a request gets first.
  </Card>
</Columns>

---

### Compute Gateway Firewall (`docs/compute/gateway/firewall.mdx`)

---
title: Firewall policy
description: "Block matching requests at the gateway with a 403 before they reach your app."
sources:
  - svc/api/openapi/spec/common/FirewallPolicy.yaml
  - svc/api/openapi/spec/common/Policy.yaml
  - svc/api/openapi/spec/common/RemoteIpMatch.yaml
  - svc/frontline/internal/policies/firewall/executor.go
  - svc/frontline/internal/policies/engine.go
  - svc/frontline/middleware/observability.go
  - svc/frontline/middleware/clickhouse_logging.go
---

A firewall policy tells the [gateway](/compute/gateway/overview) to block every request that matches its match expressions. Use it to close internal paths, block a method on a route, refuse requests with a certain header, or block IP ranges, without changing your <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip>.

To add one, open the app, go to **Policies**, click **Add policy**, and set **Type** to **Firewall**. You only need a name, an environment, and match conditions.

## Settings

<ParamField body="action" type="string" required>
  Always `ACTION_DENY`. A request the firewall doesn't match moves on to the next policy.
</ParamField>

```json Example: block writes to an admin prefix from outside
{
  "name": "Block admin writes",
  "enabled": true,
  "match": [
    { "path": { "path": { "prefix": "/admin/", "ignoreCase": true } } },
    { "method": { "methods": ["POST", "PUT", "PATCH", "DELETE"] } },
    { "header": { "name": "X-Internal-Call", "present": true } }
  ],
  "firewall": { "action": "ACTION_DENY" }
}
```

A request must match all the expressions, so the example only blocks requests that match all three. To block two unrelated kinds of request, create two firewall policies.

## Block or allow IP ranges

A `remoteIp` match with `in` blocks the listed ranges. With `notIn` it blocks everyone outside them, which turns the policy into an allowlist.

```json Example: allow /admin only from the office
{
  "name": "Admin only from the office",
  "enabled": true,
  "match": [
    { "path": { "path": { "prefix": "/admin" } } },
    { "remoteIp": { "notIn": ["198.51.100.0/24"] } }
  ],
  "firewall": { "action": "ACTION_DENY" }
}
```

The gateway uses the IP of the connection, never a header like `X-Forwarded-For`, so a client can't claim a different address. It accepts IPv4 connections today, so IPv6 entries match no request yet.

A firewall policy with no match expressions blocks every request to the <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environment</Tooltip>. That's a quick way to take a preview environment offline.

<Visibility for="agents">
  In the dashboard this is the **Firewall** type in **Policies > Add policy**. The form only asks for a name, an environment, and match conditions because `ACTION_DENY` is the only action. Programmatically, include the object above in the `policies` array of `POST /v2/gateway.setPolicies`. The change applies to the next deployment.
</Visibility>

## What the client sees

A blocked request gets `403 Forbidden` with the code [`err:frontline:client:firewall_denied`](/errors/frontline/client/firewall_denied) and the message `Forbidden`. The body is JSON or an HTML page depending on the caller's `Accept` header. See [Gateway errors](/compute/gateway/errors). The caller isn't told which policy matched.

No later policies run, and the request doesn't appear in your request log.

## Next steps

<Columns cols={2}>
  <Card title="Gateway policies" icon="list-check" href="/compute/gateway/policies">
    Every match expression type and how they combine.
  </Card>
  <Card title="Rate limit policy" icon="gauge-high" href="/compute/gateway/rate-limiting">
    Slow callers down instead of blocking them.
  </Card>
</Columns>

---

### Compute Gateway Local Development (`docs/compute/gateway/local-development.mdx`)

---
title: Local development with the gateway
sidebarTitle: "Local development"
description: "Run Frontline locally with Docker Compose to verify real API keys and inspect forwarded request headers."
sources:
  - svc/frontline/local_dev.go
  - svc/frontline/internal/router/file.go
  - svc/frontline/internal/policies/keyauth/api.go
  - svc/frontline/middleware/sanitize_headers.go
---

Frontline's `local-dev` mode runs the gateway on your machine with routes and policies from a TOML file. This example puts it in front of `traefik/whoami`, an HTTP echo service that returns request headers. No application code is needed.

A valid API key reaches the echo service with an [`X-Unkey-Principal` header](/compute/gateway/principal). Frontline rejects missing or invalid keys before they reach the service.

<Warning>
Use this example for local development only. The echo response includes the API key's `Authorization` header and principal metadata. Use development keys and keep `.env` out of version control.
</Warning>

## Prerequisites

You need Docker Compose, a keyspace, a root key with permission to verify keys in that keyspace, and an API key from that keyspace.

## Configure the containers

Create these three files in the same directory. Replace the root key and keyspace ID in `.env` with your development values.

<CodeGroup>

```yaml compose.yaml
services:
  frontline:
    image: ghcr.io/unkeyed/frontline:v1.0.26-rc.1
    command: ["--config", "/etc/unkey/gateway.toml"]
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      UNKEY_ROOT_KEY: ${UNKEY_ROOT_KEY}
      UNKEY_KEYSPACE_ID: ${UNKEY_KEYSPACE_ID}
    volumes:
      - ./gateway.toml:/etc/unkey/gateway.toml:ro
    depends_on:
      - api

  api:
    image: traefik/whoami:v1.11.0
```

```toml gateway.toml
[local-dev]
http_port = 3000
root_key = "${UNKEY_ROOT_KEY}"

[[local-dev.routes]]
hostname = "localhost"
upstream = "api:80"

[[local-dev.routes.policies]]
id = "authenticate"
enabled = true
keyauth = { key_space_ids = ["${UNKEY_KEYSPACE_ID}"] }
```

```plaintext .env
UNKEY_ROOT_KEY=replace-with-a-root-key
UNKEY_KEYSPACE_ID=replace-with-a-keyspace-id
```

</CodeGroup>

Only Frontline is exposed to your host, on loopback port 3000. The echo service is internal to the Compose network. Frontline uses `https://api.unkey.com` to verify keys by default.

The authentication policy deducts one credit from keys with limited usage for each successful request. Keys with unlimited usage are unaffected.

## Start the gateway

Run these commands from the directory containing the three files:

```bash
docker compose up -d
docker compose logs frontline
```

## Inspect the forwarded headers

Set your API key in the terminal, then send a request through Frontline:

```bash
export UNKEY_API_KEY='replace-with-an-api-key-from-your-keyspace'
curl http://localhost:3000/ \
  -H "Authorization: Bearer ${UNKEY_API_KEY}"
```

The response includes `X-Unkey-Principal`, `X-Forwarded-Host`, and `X-Forwarded-Proto: http`. Header capitalization can differ. The principal's subject is the key's identity external ID, or its key ID if it has no identity.

Send the same request without a key to check rejection:

```bash
curl -i http://localhost:3000/
```

Expect HTTP 401 instead of an echo response.

To use your own application, replace the `api` service and set `upstream` to its Compose service name and listening port. Keep its port internal so requests cannot bypass Frontline.

## Stop the example

Remove both containers and their Compose network:

```bash
docker compose down
```

## Next steps

<Columns cols={2}>
  <Card title="The principal header" icon="id-badge" href="/compute/gateway/principal">
    Read every field and when it is omitted.
  </Card>
  <Card title="Gateway policies" icon="list-check" href="/compute/gateway/policies">
    Add authentication, rate limits, and other rules to your app.
  </Card>
</Columns>

---

### Compute Gateway Logging (`docs/compute/gateway/logging.mdx`)

---
title: Logging policy
description: "Add headers, query data, and bodies to the request log for the requests you choose."
sources:
  - svc/api/openapi/spec/common/LoggingPolicy.yaml
  - svc/frontline/internal/policies/engine.go
  - svc/frontline/routes/proxy/handler.go
  - svc/frontline/middleware/clickhouse_logging.go
  - svc/frontline/internal/policies/redaction.go
  - pkg/zen/session.go
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/policies/policy-kinds/logging/model.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/policies/policy-kinds/logging/fields.tsx
---

The [gateway](/compute/gateway/overview) logs the method, host, path, status, and latency of every request that reaches your <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip>. A logging policy also saves headers, query data, or bodies for the requests it matches. Use it to debug a route. It never rejects a request or changes what your app receives.

To add one, open the app, go to **Policies**, click **Add policy**, and set **Type** to **Logging**.

## Settings

Each setting turns on one kind of data. In the dashboard all five start on, so check the switches before you save. In the API they all default to `false`, so a policy with none set saves nothing extra.

<ParamField body="requestHeaders" type="boolean" default="false">
  Save request headers, plus the user agent and client IP.
</ParamField>

<ParamField body="responseHeaders" type="boolean" default="false">
  Save response headers.
</ParamField>

<ParamField body="requestBody" type="boolean" default="false">
  Save the request body, up to 1 MiB.
</ParamField>

<ParamField body="responseBody" type="boolean" default="false">
  Save the response body, up to 1 MiB.
</ParamField>

<ParamField body="query" type="boolean" default="false">
  Save the query string and parameters. It's separate from headers because URLs often carry secrets like `?api_key=...`.
</ParamField>

```json Example: capture everything on one debugging route
{
  "name": "Debug the webhook receiver",
  "enabled": true,
  "match": [{ "path": { "path": { "exact": "/webhooks/stripe" } } }],
  "logging": {
    "requestHeaders": true,
    "responseHeaders": true,
    "requestBody": true,
    "responseBody": true,
    "query": true
  }
}
```

<Visibility for="agents">
  In the dashboard this is the **Logging** type in **Policies > Add policy**, with one switch per flag, all on by default. Programmatically, include the object above in the `policies` array of `POST /v2/gateway.setPolicies`. Omitted flags are `false`. The change applies to the next deployment.
</Visibility>

## What gets saved

- **Several matching policies combine.** A request matched by a `requestHeaders` policy and a `requestBody` policy gets both saved. A policy with no match expressions applies to every request.
- **Bodies are cut off at 1 MiB in each direction** in the log. The request and response themselves are never cut. Streaming requests are covered too.
- **Rejected requests aren't logged**, whatever the logging policy says.

## What's always redacted

These are replaced with `[REDACTED]` before anything is saved:

- The `Authorization` header.
- Every header and query parameter listed as a key location in any [API key authentication policy](/compute/gateway/api-key-auth), even turned-off ones. When a query parameter is redacted, the saved query string can be in a different order or encoding than the original.
- Body fields marked `x-unkey-redact: true` in your OpenAPI spec, when an [OpenAPI validation policy](/compute/gateway/openapi-validation) is on.

## Where to see the data

Saved data shows up in the project's **Requests** view in the dashboard and in the `gateway_requests_v1` analytics table. The header, query, and body columns are empty for requests no logging policy matched.

## Next steps

<Columns cols={2}>
  <Card title="OpenAPI validation policy" icon="file-code" href="/compute/gateway/openapi-validation">
    Mark body fields for redaction with `x-unkey-redact`.
  </Card>
  <Card title="Gateway policies" icon="list-check" href="/compute/gateway/policies">
    Match expressions that pick which requests to capture.
  </Card>
</Columns>

---

### Compute Gateway Openapi Validation (`docs/compute/gateway/openapi-validation.mdx`)

---
title: OpenAPI validation policy
description: "Reject requests that do not match your OpenAPI spec before they reach your app."
sources:
  - svc/api/openapi/spec/common/OpenapiPolicy.yaml
  - svc/api/openapi/spec/paths/v2/environments/updateSettings/V2EnvironmentsUpdateSettingsRequestBody.yaml
  - svc/ctrl/worker/deploy/deploy_handler.go
  - svc/ctrl/worker/openapi/scrape_handler.go
  - svc/frontline/internal/router/lookup.go
  - svc/frontline/internal/policies/openapi/executor.go
  - svc/frontline/proto/frontline/policies/v1/openapi.proto
  - pkg/openapi/validation/validator.go
  - pkg/redaction/doc.go
  - pkg/redaction/openapi.go
  - svc/frontline/middleware/observability.go
---

An OpenAPI validation policy tells the [gateway](/compute/gateway/overview) to check each matching request against your <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip>'s OpenAPI spec. Requests that don't match get a `400`, so your code only sees requests the spec allows. The path, query parameters, headers, and body are all checked. OpenAPI 3.0 and 3.1 are supported.

## Set it up

1. Serve your OpenAPI document from your app, for example at `/openapi.yaml`.
2. Set that path as `openapiSpecPath` in the <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environment</Tooltip>'s [runtime settings](/compute/configure/runtime-settings), under **App settings** > **Advanced** or with `environments.updateSettings`. It must start with `/` and be at most 512 characters.
3. Add the policy: open the app, go to **Policies**, click **Add policy**, and set **Type** to **OpenAPI Validation**. There's nothing else to configure.
4. Deploy. When the <Tooltip tip="One built and running version of an app in one environment.">deployment</Tooltip> succeeds, we fetch the spec from it over HTTPS and save it with that deployment.

The policy has no settings. In the API it's an empty object:

```json Example
{
  "name": "Validate the public API",
  "enabled": true,
  "match": [{ "path": { "path": { "prefix": "/v1/" } } }],
  "openapi": {}
}
```

<Visibility for="agents">
  In the dashboard this is the **OpenAPI Validation** type in **Policies > Add policy**. The form has no type-specific fields. Its **Then** row shows "Uses the auto-scraped spec." and a **Configure scrape path** link to **App settings** > **Advanced**. Programmatically, include the object above in the `policies` array of `POST /v2/gateway.setPolicies`, and set `openapiSpecPath` with `POST /v2/environments.updateSettings` so the next deployment is scraped. Both changes apply to the next deployment.
</Visibility>

Each deployment checks requests against the spec it was deployed with. A spec change applies when you deploy the version that serves it.

## What callers see

A request that fails the check gets `400` [`openapi_validation_failed`](/errors/frontline/client/openapi_validation_failed). The message describes the first problem and, when there is one, the field that caused it. No later policies run, and the request isn't logged.

An `Authorization` header with the wrong scheme isn't reported here, because the [API key authentication policy](/compute/gateway/api-key-auth) gives a clearer error. A missing `Authorization` header that the spec requires still fails.

## Troubleshooting

### Requests aren't being checked

If the deployment has no spec, the policy does nothing and requests pass through. A deployment has no spec when:

- `openapiSpecPath` isn't set.
- The path returns a `404` or an empty response.
- The document is over 10 MiB.
- Another policy blocked the request for the spec. We fetch it through the gateway like any other request, so keep the spec path out of your API key and firewall policies' match expressions.

A missing spec never fails the deployment. Fix the cause and redeploy.

### Every request fails with 422

If the spec is invalid, for example it has a broken schema, every matching request gets `422` [`invalid_configuration`](/errors/frontline/config/invalid_configuration). Fix the spec and redeploy.

## Hide fields in request logs

Add `x-unkey-redact: true` to a property in your spec, and its value is hidden in request and response bodies saved by a [logging policy](/compute/gateway/logging). Only that property is hidden, not others with the same name elsewhere in your schemas. Your app still receives the full request.

```yaml
components:
  schemas:
    CreateUser:
      type: object
      properties:
        email:
          type: string
        password:
          type: string
          x-unkey-redact: true
```

## Next steps

<Columns cols={2}>
  <Card title="Logging policy" icon="file-lines" href="/compute/gateway/logging">
    Capture bodies so redaction has something to protect.
  </Card>
  <Card title="Gateway errors" icon="triangle-exclamation" href="/compute/gateway/errors">
    The shape of the 400 and 422 responses.
  </Card>
</Columns>

---

### Compute Gateway Policies (`docs/compute/gateway/policies.mdx`)

---
title: Gateway policies
sidebarTitle: "Policies"
description: "Add gateway policies to an environment, choose which requests they apply to, and put them in order."
sources:
  - svc/api/openapi/spec/common/Policy.yaml
  - svc/api/openapi/spec/common/PolicyResponse.yaml
  - svc/api/openapi/spec/common/MatchExpr.yaml
  - svc/api/openapi/spec/common/PathMatch.yaml
  - svc/api/openapi/spec/common/MethodMatch.yaml
  - svc/api/openapi/spec/common/FieldMatch.yaml
  - svc/api/openapi/spec/common/StringMatch.yaml
  - svc/api/openapi/spec/common/RemoteIpMatch.yaml
  - svc/api/openapi/spec/paths/v2/gateway/setPolicies/V2GatewaySetPoliciesRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/gateway/updatePolicy/V2GatewayUpdatePolicyRequestBody.yaml
  - svc/api/openapi/spec/paths/v2/gateway/listPolicies/V2GatewayListPoliciesRequestBody.yaml
  - svc/api/routes/v2_gateway_set_policies/handler.go
  - svc/api/routes/v2_gateway_update_policy/handler.go
  - svc/api/routes/v2_gateway_list_policies/handler.go
  - svc/frontline/internal/policies/engine.go
  - svc/frontline/internal/policies/match.go
  - svc/ctrl/worker/deploy/create.go
  - svc/frontline/internal/db/queries/ingress_route_find_by_fqdn.sql
  - pkg/rbac/permissions.go
  - cmd/api/gateway/set_policies.go
  - cmd/api/gateway/update_policy.go
  - cmd/api/gateway/list_policies.go
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/policies/components/policy-form/add-policy-panel.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/policies/components/policy-form/edit-policy-panel.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/policies/components/policy-form/rule/index.tsx
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/policies/policy-kinds/conditions/kinds.ts
  - web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/policies/model/duplicate-name.ts
---

A policy is one rule the [gateway](/compute/gateway/overview) applies to the requests you pick, such as "require an API key" or "100 requests per minute". Each <Tooltip tip="A production or preview environment of a Compute app, not the dashboard label on a key.">environment</Tooltip> of an <Tooltip tip="A Compute app: a deployable service inside a project. Not 'your application' in general.">app</Tooltip> has its own list, so production and preview can have different rules.

<Warning>
Each <Tooltip tip="One built and running version of an app in one environment.">deployment</Tooltip> keeps the policies it was created with. Adding, editing, turning off, or deleting a policy only affects the next deployment. Redeploy to apply a change.
</Warning>

## Add a policy in the dashboard

1. Open the app and click **Policies** in the sidebar.
2. Click **Add policy**, enter a **Name**, and pick a **Type**: Key Auth, Rate Limit, Firewall, OpenAPI Validation, or Logging.
3. In the **Rule** card, click **Add condition** for each match condition if the policy should only apply to some requests. With no conditions, the **If** row reads "Every request". Open the **Then** row to set the type's settings.
4. Pick the **Environment**. The default, **All environments**, adds the same policy to both production and preview.
5. Click **Add policy**, then redeploy.

<Frame>
  <img src="/images/dashboard/compute--gateway-policies--policies.png" alt="Add policy panel with the Name and Type fields, the Rule card with If and Then rows, and the Environment select set to All environments" />
</Frame>

Click a policy's row to edit it in the same panel. The type can't change there. The **Environments** switches in that panel turn the policy on or off in production and preview. **How policies work** on the page header explains the evaluation order and what each type rejects.

You can't have two policies with the same type and name in one environment. The dashboard shows a policy with the same type and name in both environments as one row, and suggests opening the existing policy to add it to the other environment instead.

<Visibility for="agents">
  The dashboard steps above correspond to `POST /v2/gateway.setPolicies` with the environment's complete policy list, or `POST /v2/gateway.updatePolicy` to change one policy. To scope a policy to one environment, call the endpoint once with that environment's slug. Read the current list first with `POST /v2/gateway.listPolicies`. The ids it returns are the only valid `policyId` values for `updatePolicy`.
</Visibility>

## Set policies with the API

Each endpoint takes the `project`, `app`, and `environment` by ID or slug.

- **`gateway.listPolicies`** returns the list in the order it runs, with IDs.
- **`gateway.setPolicies`** replaces the whole list with the `policies` array you send, in that order. If any policy is invalid, nothing is saved. An empty array removes every policy. Every policy gets a new ID.
- **`gateway.updatePolicy`** changes one policy by `policyId`. Fields you leave out keep their values, and `match: null` removes all match expressions. Sending one of `keyauth`, `ratelimit`, `firewall`, `openapi`, or `logging` replaces the rule and can change its type.

```bash
curl -X POST https://api.unkey.com/v2/gateway.setPolicies \
  -H "Authorization: Bearer $UNKEY_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "project": "my-project",
    "app": "api",
    "environment": "production",
    "policies": [
      {
        "name": "Require a key",
        "enabled": true,
        "keyauth": { "keyspaces": ["ks_1234abcd"] }
      },
      {
        "name": "100 per minute per key",
        "enabled": true,
        "ratelimit": {
          "limit": 100,
          "windowMs": 60000,
          "identifiers": [{ "authenticatedSubject": {} }]
        }
      }
    ]
  }'
```

Your root key needs `environment.*.set_policies` to replace the list, `environment.*.read_policies` to list, and `environment.*.update_policy` to change one, or the same actions on `environment.<environment_id>.…`. See [root key permissions](/platform/root-keys/permissions). A keyspace in an authentication policy must be in the same workspace, or the call fails with [`err:unkey:data:key_space_not_found`](/errors/unkey/data/key_space_not_found).

Every change, from the dashboard, API, or CLI, is recorded in the audit log with the full policy list.

## Set policies with the CLI

`unkey api gateway list-policies`, `set-policies`, and `update-policy` do the same as the three endpoints. Each takes `--project`, `--app`, and `--environment`. `set-policies` takes the full list as `--policies '<json array>'`, and `update-policy` takes `--policy-id` and `--policy '<json object>'`.

## What's in a policy

<ParamField body="name" type="string" required>
  Shown in the dashboard. 1 to 256 characters.
</ParamField>

<ParamField body="enabled" type="boolean" required>
  A turned-off policy is kept but skipped.
</ParamField>

<ParamField body="match" type="MatchExpr[]">
  Up to 10 expressions. A request must match all of them for the policy to apply. Leave it out to apply the policy to every request.
</ParamField>

<ParamField body="keyauth | ratelimit | firewall | openapi | logging" type="object" required>
  Set exactly one. It picks the policy type and holds its settings. See [API key authentication](/compute/gateway/api-key-auth), [Rate limit](/compute/gateway/rate-limiting), [Firewall](/compute/gateway/firewall), [OpenAPI validation](/compute/gateway/openapi-validation), and [Logging](/compute/gateway/logging).
</ParamField>

Saved policies also have an `id`. It changes every time `gateway.setPolicies` replaces the list.

## Pick which requests a policy applies to

Each match expression sets exactly one of `path`, `method`, `header`, `queryParam`, or `remoteIp`. A request must match every expression in the policy. To match one thing or another, create two policies.

<ParamField body="path.path" type="StringMatch">
  Matches the request path. Set exactly one of `exact`, `prefix`, or `regex` (1 to 1024 characters), and optionally `ignoreCase: true`. Regular expressions use RE2 syntax, and one that doesn't compile is rejected when you save.
</ParamField>

<ParamField body="method.methods" type="string[]">
  One or more of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`. Matches any of them, ignoring case.
</ParamField>

<ParamField body="header" type="FieldMatch">
  `name` (1 to 256 characters) plus exactly one of `present: true` or `value` (a `StringMatch` like `path`). With `value`, it matches if any value of that header matches.
</ParamField>

<ParamField body="queryParam" type="FieldMatch">
  Same as `header`, for a query parameter. Names are case-sensitive.
</ParamField>

<ParamField body="remoteIp" type="RemoteIpMatch">
  Matches the remote IP. Set exactly one of `in` (the remote IP is in one of the ranges) or `notIn` (the remote IP is in none of them). Each is a list of 1 to 100 IP addresses or CIDRs, such as `203.0.113.7`, `203.0.113.0/24`, or `2001:db8::/32`. A single address is saved as `/32` or `/128`. Ranges with host bits set, such as `10.1.2.3/8`, are rejected. The gateway accepts IPv4 connections today, so IPv6 entries match no request yet. In the dashboard this is **Client IP**, with **is in** or **is not in**.
</ParamField>

```json Example: block POST and DELETE on internal paths
{
  "name": "Block internal paths",
  "enabled": true,
  "match": [
    { "path": { "path": { "prefix": "/internal/" } } },
    { "method": { "methods": ["POST", "DELETE"] } }
  ],
  "firewall": { "action": "ACTION_DENY" }
}
```

## Put policies in the right order

The gateway runs every enabled policy that matches, from top to bottom. In the dashboard, drag a row by its handle to reorder. Order matters in four ways:

- **IP blocks before authentication.** Put a firewall policy that blocks by `remoteIp` above your API key policies, so blocked clients can't try keys.
- **Authentication first.** The first API key policy that succeeds decides who the caller is. Later API key policies are skipped.
- **Rate limits by caller go after authentication.** A rate limit that counts by `authenticatedSubject` or `principalField` needs to know the caller. If it runs first, every request counts as `unknown`.
- **A rejection stops everything.** After a firewall block, a failed key check, a used-up rate limit, or an OpenAPI error, no later policy runs, including logging.

Logging policies never reject. If several match, the request is logged with all of their settings combined.

## Limits

- 50 policies per environment.
- 10 match expressions per policy.
- 100 entries per `remoteIp` list.
- 5 keyspaces and 10 key rate limits per API key policy.
- 5 identifiers per rate limit policy.
- Match strings up to 1024 characters, and names up to 256.

## Next steps

<Columns cols={2}>
  <Card title="API key authentication policy" icon="key" href="/compute/gateway/api-key-auth">
    Verify Unkey keys and forward the principal.
  </Card>
  <Card title="Rate limit policy" icon="gauge-high" href="/compute/gateway/rate-limiting">
    Limit by IP, header, path, subject, or principal field.
  </Card>
  <Card title="Firewall policy" icon="shield" href="/compute/gateway/firewall">
    Deny the requests your match expressions select.
  </Card>
  <Card title="Logging policy" icon="file-lines" href="/compute/gateway/logging">
    Capture headers, query data, and bodies for matched requests.
  </Card>
</Columns>

---

--- METRICS ---
- Files Extracted: 101
- Estimated Token Budget: ~139393 tokens
- Canonical Reference: https://codewiki.google/github.com/unkeyed/unkey
