---
title: "API keys"
description: "Machine credentials, scoped to a subset of the permission catalog."
url: "https://saved.sh/docs/api/api-keys"
---

An API key is a machine credential bound to one workspace, carrying permissions an admin
chooses at creation.

All four endpoints require `api-keys:*` permissions, which are **human-only**. A machine key
cannot manage keys, including itself, so a leaked key cannot mint more.

## Create [#create]

```bash
curl -fsS -X POST https://api.saved.sh/v1/api-keys \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name": "ci-backups", "permissions": ["backups:read", "backups:write"]}'
```

```json
{
  "id": "7c1e...",
  "name": "ci-backups",
  "permissions": ["backups:read", "backups:write"],
  "secret": "sk_live_...",
  "created_at": "2026-08-08T09:14:02Z",
  "updated_at": "2026-08-08T09:14:02Z"
}
```

Permission: `api-keys:write`. Returns `201`.

<Callout type="error">
  **`secret` appears in this response and nowhere else, ever.** It is not stored by us, and no
  endpoint returns it again. Capture it here or create a new key.
</Callout>

## Refused permissions [#refused-permissions]

| Refused                                              | Why                                                                                     |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `workers:read`, `workers:write`, `workers:revoke`    | A compromised key could mint worker credentials                                         |
| `api-keys:read`, `api-keys:write`, `api-keys:revoke` | One leaked key could mint more, and revoking the original would not revoke what it made |
| `billing:read`, `billing:write`                      | The workspace's cards                                                                   |
| `destinations:write`                                 | Linking a bucket hands over a long-lived S3 key pair                                    |
| `connections:write`                                  | The OAuth flow ends at a browser consent screen a machine cannot complete               |
| `artifacts:confirm`                                  | Worker-only                                                                             |

```json
{
  "error": {
    "code": "invalid_request",
    "message": "permission api-keys:write cannot be granted to a machine key",
    "request_id": "req_..."
  }
}
```

Returned as `422`.

<Callout>
  This is enforced **twice**: at creation, and again by stripping human-only slugs from the
  principal on every validation. The second layer exists because creation-time validation
  cannot reach a key minted before the gate existed, or edited directly in the identity
  provider. A stripped permission is logged, which means an over-granted key is in the wild and
  wants revoking.
</Callout>

`destinations:read` and `connections:read` return metadata only, never a credential or token,
and **are** available to machine keys so automation can reconcile a delivery plan.

## List [#list]

```bash
curl -fsS https://api.saved.sh/v1/api-keys -H "Authorization: Bearer $TOKEN"
```

```json
{
  "data": [
    { "id": "7c1e...", "name": "ci-backups", "permissions": ["backups:read", "backups:write"],
      "created_at": "2026-08-08T09:14:02Z", "updated_at": "2026-08-08T09:14:02Z" }
  ],
  "next_cursor": null
}
```

Permission: `api-keys:read`. No secret is returned.

Worth running as part of offboarding, since **keys outlive the people who created them**.

## Get [#get]

```bash
curl -fsS "https://api.saved.sh/v1/api-keys/$KEY_ID" -H "Authorization: Bearer $TOKEN"
```

## Revoke [#revoke]

```bash
curl -fsS -X DELETE "https://api.saved.sh/v1/api-keys/$KEY_ID" -H "Authorization: Bearer $TOKEN"
```

Permission: `api-keys:revoke`. Returns `204`.

<Callout type="warn">
  **Revocation is not instantaneous.** Validations are cached for about a minute, so a revoked
  key may keep working briefly. Treat it as effective within a minute, and if the key leaked,
  rotate whatever it could reach: source credentials for backups it could reconfigure, and
  destination credentials if it could read them.
</Callout>

## Rotation [#rotation]

There is no rotate endpoint. Rotation is create, deploy, verify, revoke:

```bash
NEW=$(curl -fsS -X POST https://api.saved.sh/v1/api-keys \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"ci-backups-2026-08","permissions":["backups:read","backups:write"]}' \
  | jq -r .secret)

# deploy $NEW, confirm traffic works, then:
curl -fsS -X DELETE "https://api.saved.sh/v1/api-keys/$OLD_KEY_ID" -H "Authorization: Bearer $TOKEN"
```

The asymmetry with workers is deliberate: a worker keeps its ID through a rotation because
backups point at it, while an API key is only ever referenced by the systems holding it.

## Naming [#naming]

Name keys after their **use**, not their creator: `ci-deploy`, `monitoring`, not `sams-key`.
Keys belong to the workspace and survive the person, so a review months later has to be
possible from the name alone.

## Quotas [#quotas]

| Plan  | API keys per workspace |
| ----- | ---------------------- |
| Trial | 2                      |
| Paid  | 20                     |

## Next [#next]

<Cards>
  <Card href="/docs/api/workers" title="Workers" description="The other machine credential." />

  <Card href="/docs/security/api-keys" title="API key security" description="Storage, rotation and what a leak means." />

  <Card href="/docs/security/permissions" title="Permissions" description="The full catalog." />
</Cards>
