API keys
Machine credentials, scoped to a subset of the permission catalog.
View as MarkdownAn 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
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"]}'{
"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.
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.
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 |
{
"error": {
"code": "invalid_request",
"message": "permission api-keys:write cannot be granted to a machine key",
"request_id": "req_..."
}
}Returned as 422.
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.
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
curl -fsS https://api.saved.sh/v1/api-keys -H "Authorization: Bearer $TOKEN"{
"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
curl -fsS "https://api.saved.sh/v1/api-keys/$KEY_ID" -H "Authorization: Bearer $TOKEN"Revoke
curl -fsS -X DELETE "https://api.saved.sh/v1/api-keys/$KEY_ID" -H "Authorization: Bearer $TOKEN"Permission: api-keys:revoke. Returns 204.
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.
Rotation
There is no rotate endpoint. Rotation is create, deploy, verify, revoke:
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
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
| Plan | API keys per workspace |
|---|---|
| Trial | 2 |
| Paid | 20 |