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 keyOne-time — used only on POST /v1/enroll to register a device
Refresh tokenLong-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.
Creating a project uses a global admin token configured on the API (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
Access tokens expire (~30 min). When one expires, repeat step 2 with your refresh token to mint a new one.
POST /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

400Missing project
401Invalid setup key
POST /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

401Invalid or revoked refresh token
POST /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

401Invalid refresh token
POST /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

400Missing or invalid name
401Invalid or missing admin token
409Project already exists
GET /v1/projects

List all project names. Requires the global admin token.

Response — 200 OK

{
  "projects": ["payments-service", "auth-service"]
}
POST /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

400Invalid role
401Admin authorization required
404Project not found
POST /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
}
GET /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" }
]
GET /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

401Invalid or missing access token
403Write-only token cannot read secrets
404Secret not found
PUT /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

400Missing or empty value
401Invalid or missing access token
403Read-only token cannot set secrets (write access required)
DEL /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

401Invalid or missing access token
403Read-only token cannot delete secrets (write access required)
404Secret not found
GET /health

Health check endpoint. No authentication required. Used by load balancers and monitoring.

Response — 200 OK

{
  "status": "ok"
}