API Reference
All endpoints are served over HTTPS at https://api.getkeyctl.com. The API is fully usable with curl alone — no CLI binary required.
Authentication
keyctl uses a three-tier credential model. Each credential is a Bearer token used at a different stage:
| Setup key | One-time — used only on POST /v1/enroll to register a device |
| Refresh token | Long-lived, revocable — used on POST /v1/token and /v1/revoke |
| Access token (JWT) | ~30 min, minted per session — used on all secret endpoints |
The flow is: exchange your one-time setup key for a refresh token (enroll), then exchange the refresh token for a short-lived access token (JWT), then use the access token to read/write secrets. The setup key does not work on secret endpoints.
Setup key roles
Every setup key carries a role, enforced by the server. The role of the access token you enroll into determines what you can do:
write-only (-wo) | Add/update secrets, but cannot read them. Safe to commit into a repo's keyctl.yaml — a leak can't exfiltrate secrets. |
read-only (-ro) | Read secrets only. Commit only when repo holders are trusted to read. |
admin (-admin) | Read + write + rotate/issue keys. The master key — never commit it. |
KEYCTL_ADMIN_TOKEN) and returns the admin master key. Minting additional keys (e.g. a write-only key to commit) uses POST /v1/projects/{project}/setup-key, authorized by an admin.
Use the API without the CLI
If you can't download or run the binary, you can do everything the CLI does with curl. This is the recommended fallback.
1. Enroll (setup key → refresh token)
curl -X POST https://api.getkeyctl.com/v1/enroll \
-H "Authorization: Bearer $SETUP_KEY" \
-H "Content-Type: application/json" \
-d '{"project":"payments-service","deviceName":"my-laptop"}'
# => { "refreshToken": "payments-service.ab12...c9", "role": "admin", ... }
2. Get an access token (refresh token → JWT)
curl -X POST https://api.getkeyctl.com/v1/token \
-H "Authorization: Bearer $REFRESH_TOKEN"
# => { "accessToken": "eyJhbGci...", "expiresAt": "...", "role": "admin" }
3a. Add / update a secret (write-only or admin token)
curl -X PUT https://api.getkeyctl.com/v1/projects/payments-service/secrets/database-url \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"value":"postgres://..."}'
# => { "key": "database-url", "status": "ok" }
3b. Read a secret (read-only or admin token)
curl https://api.getkeyctl.com/v1/projects/payments-service/secrets/database-url \ -H "Authorization: Bearer $ACCESS_TOKEN" # => { "key": "database-url", "value": "postgres://..." } # A write-only credential gets 403 here — that is expected.
4. Inject into your environment (no CLI)
# fetch a secret straight into an env var, then run your app
export DATABASE_URL=$(curl -s \
https://api.getkeyctl.com/v1/projects/payments-service/secrets/database-url \
-H "Authorization: Bearer $ACCESS_TOKEN" | python3 -c 'import sys,json;print(json.load(sys.stdin)["value"])')
node server.js
/v1/enroll
Exchange a one-time setup key for a device-bound refresh token. The setup key is used only here.
Authorization
Bearer = the project setup key.
Request body
{
"project": "payments-service",
"deviceName": "my-laptop" // optional
}
Response — 201 Created
{
"refreshToken": "payments-service.ab12...c9", // store securely
"deviceId": "ab12cd34ef56",
"project": "payments-service",
"role": "admin"
}
Error codes
| 400 | Missing project |
| 401 | Invalid setup key |
/v1/token
Exchange a refresh token for a short-lived JWT access token (~30 min).
Authorization
Bearer = the refresh token.
Response — 200 OK
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expiresAt": "2026-07-06T11:48:58Z",
"role": "admin"
}
Error codes
| 401 | Invalid or revoked refresh token |
/v1/revoke
Revoke a device by deleting its registration. Existing access tokens stop working once they expire (within ~30 min).
Authorization
Bearer = the refresh token being revoked.
Response — 204 No Content
Error codes
| 401 | Invalid refresh token |
/v1/projects
Create a new project and receive its initial admin setup key. The key is returned exactly once and never stored in plaintext.
Authorization
Requires the global KEYCTL_ADMIN_TOKEN as the Bearer token.
Request body
{
"name": "payments-service"
}
Response — 201 Created
{
"project": "payments-service",
"setupKey": "3f8a2c1b..." // save this — shown once only
}
Error codes
| 400 | Missing or invalid name |
| 401 | Invalid or missing admin token |
| 409 | Project already exists |
/v1/projects
List all project names. Requires the global admin token.
Response — 200 OK
{
"projects": ["payments-service", "auth-service"]
}
/v1/projects/{project}/setup-key
Mint a new setup key for an existing project. Use this to get a key to commit into a repo's keyctl.yaml. The project must already exist (create it with POST /v1/projects).
Authorization
Bearer = an admin access token for the project, or the global admin token.
Request body (optional)
{
"role": "writeonly" // "writeonly" (default), "readonly", or "admin"
}
Response — 201 Created
{
"project": "payments-service",
"setupKey": "7d3b9e1a...", // shown once — commit as keyctl.yaml setupKey
"role": "writeonly"
}
Error codes
| 400 | Invalid role |
| 401 | Admin authorization required |
| 404 | Project not found |
/v1/projects/{project}/rotate-key
Generate a new admin setup key and revoke all previous ones. After calling this endpoint, prior setup keys can no longer enroll.
Authorization
Bearer = an admin access token for the project.
Response — 200 OK
{
"setupKey": "a91f4c2e..." // new key — shown once only
}
/v1/projects/{project}/secrets
List all secret keys (names) for a project. Values are never included in list responses.
Authorization
Bearer = an access token (JWT). See Use the API without the CLI.
Response — 200 OK
[
{ "key": "database-url", "updatedAt": "2026-07-06T09:00:00Z" },
{ "key": "stripe-secret-key", "updatedAt": "2026-07-05T14:30:00Z" }
]
/v1/projects/{project}/secrets/{key}
Retrieve a single secret value. Requires a read-capable token (read-only or admin). Write-only tokens are rejected.
Authorization
Bearer = a read-only or admin access token (JWT).
Response — 200 OK
{
"key": "database-url",
"value": "postgres://user:pass@host:5432/db"
}
Error codes
| 401 | Invalid or missing access token |
| 403 | Write-only token cannot read secrets |
| 404 | Secret not found |
/v1/projects/{project}/secrets/{key}
Create or update a secret value. Requires a write-capable token (write-only or admin).
Request body
{
"value": "postgres://user:pass@host:5432/db"
}
Response — 200 OK
{
"key": "database-url",
"status": "ok"
}
Error codes
| 400 | Missing or empty value |
| 401 | Invalid or missing access token |
| 403 | Read-only token cannot set secrets (write access required) |
/v1/projects/{project}/secrets/{key}
Delete a secret. Requires a write-capable token (write-only or admin). In soft-delete enabled vaults, the secret enters a deleted state and can be purged separately.
Authorization
Bearer = a write-only or admin access token.
Response — 204 No Content
Error codes
| 401 | Invalid or missing access token |
| 403 | Read-only token cannot delete secrets (write access required) |
| 404 | Secret not found |
/health
Health check endpoint. No authentication required. Used by load balancers and monitoring.
Response — 200 OK
{
"status": "ok"
}