Skip to content

API keys over the API

Keys are normally made in the dashboard, under Settings → API keys, and Authentication covers that. This page is the same job done over HTTP, for software that provisions its own credentials.

MethodPathPermission
POST/auth/send-otpnone
POST/auth/verify-otpnone
GET/keysManage API keys
POST/keysManage API keys
POST/keys/{id}/rotateManage API keys
DELETE/keys/{id}Manage API keys
GET/keys/{id}/eventsManage API keys

First key by email

The two auth routes are the only ones in the API that take no Authorization header, because the caller has no credential yet. They register an account, or add a key to an existing one, and hand the key back.

curl -s -X POST https://statable.com/api/v1/auth/send-otp \
  -H "Content-Type: application/json" \
  -d '{"email": "agent@example.com"}'
{"sent": true, "email": "agent@example.com"}

The answer is the same whether or not the address is already registered. Nothing here can be used to find out who has an account.

Then exchange the emailed code:

curl -s -X POST https://statable.com/api/v1/auth/verify-otp \
  -H "Content-Type: application/json" \
  -d '{"email": "agent@example.com", "code": "123456",
       "accept_terms": true, "key_name": "provisioning bot"}'
FieldRequiredNotes
emailyesThe address the code went to
codeyesSingle use, three attempts before it is destroyed
accept_termsyesMust be true, or 400 terms_not_accepted and no account is created
terms_versionnoDefaults to the current version
key_nameno1 to 100 characters. Defaults to agent bootstrap
scopesnoDefaults to everything the bootstrap allows, read and sites:write
expires_in_daysno1 to 365, default 90

The response carries token once, alongside the key and the account. created is false when the address already had an account, in which case this simply adds a key to it.

Three things worth knowing before you build on it:

  • The key always covers all sites and always expires. At registration there is nothing to scope it to, and a credential held by software has to age out.
  • billing:write cannot be obtained here. Asking for a scope outside the bootstrap allowance is 400 invalid_scope, never a quiet downgrade. Authority that moves money should not follow from proving control of an inbox.
  • Accepting the terms is recorded, with the version, the IP and the User-Agent. An account created without a browser still needs an evidence trail.

Rate limits are the login ones: two codes per address per minute, five per IP per hour, and twenty requests per IP per hour across both routes.

List keys

GET /api/v1/keys

The account's active keys, newest first. No token or hash ever appears here, only at creation and rotation.

{
  "keys": [
    {
      "id": 41,
      "name": "deploy bot",
      "prefix": "stbl_A1b2C3d4",
      "website_id": null,
      "scopes": "read,keys:manage",
      "created_at": "2026-07-01T10:12:03Z",
      "created_ip": "203.0.113.7",
      "last_used_at": "2026-07-27T08:44:00Z",
      "last_used_ip": "203.0.113.7",
      "expires_at": "2026-09-29T10:12:03Z"
    }
  ]
}

website_id: null means all of the owner's sites. expires_at: null means it never expires, which only keys made in the dashboard can be.

Create a key

POST /api/v1/keys
FieldRequiredNotes
nameyes1 to 100 characters
scopesnoDefaults to ["read"]
website_idnoLock the key to one site. Omit for all sites
expires_in_daysno1 to 365, default 90
curl -s -X POST https://statable.com/api/v1/keys \
  -H "Authorization: Bearer stbl_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "reporting job", "scopes": ["read"], "expires_in_days": 30}'

The response is the key plus token, shown exactly once. Store it immediately: only a hash is kept, and support cannot recover it.

Four rules

Together they mean a key can never hand out more authority than it holds. There is no master key.

Scopes must be a subset. Requesting more than the acting key carries is 403 scope_escalation. A scope outside the vocabulary is 400 invalid_scope.

Site scope must be a subset. A single-site key may only create keys locked to its own site. Asking for another site, or for an all-sites key, is 403 key_not_scoped.

Expiry has a ceiling. Default 90 days, maximum 365, and anything outside 1 to 365 is 400 invalid_expiry. Unlike the dashboard, the API cannot mint a key that never expires.

A key cannot modify itself. Rotating or revoking the key you are authenticating with is 409 self_modification, so software cannot destroy the credential it is halfway through using. Use a second key, or the dashboard.

The account cap still applies: ten active keys, and over it 400 key_limit_reached.

Rotate and revoke

POST /api/v1/keys/{id}/rotate
DELETE /api/v1/keys/{id}

Rotate issues a fresh secret in place. Same id, name, site scope, scopes and expiry; the old token stops working at once. No request body, and the response matches create, including the one-time token.

Revoke answers 204 No Content and the token stops authenticating immediately. An unknown, already revoked, or someone else's key is 404 api_key_not_found.

Key history

GET /api/v1/keys/{id}/events

Create, rotate and revoke for one key, newest first. Revoked keys keep their trail.

{
  "events": [
    {
      "event": "rotated",
      "ip": "203.0.113.7",
      "user_agent": "deploy-bot/2.1",
      "created_at": "2026-07-27T12:30:00Z",
      "actor_key_id": 41
    }
  ]
}

actor_key_id names the key that performed the action, or is null when it came from the dashboard.

These endpoints hand out credentials

A key with Manage API keys can mint others within its own limits, so treat it as the most sensitive thing you hold. Give a reporting job Read analytics and nothing more, and keep key management to a credential that does not travel.

Next steps


Ready to take control of your web analytics? Try Statable free for 30 days. No credit card required, full feature access, built for GDPR. Start your free trial or view a live demo.