Authentication & API keys
There are two ways to prove who you are to the API: a login token (a JWT) or an API key. Use whichever fits — both point at the same account, so you'll see the same projects and wallet either way. This page explains what each one is, when to reach for it, and how to create and manage keys.
A few endpoints need no credential at all — registration, login, the pricing and discovery reads, and the OpenAPI document itself. Those are marked as public on their operation page. Everything else needs one of the two credentials below.
The two credentials
| Credential | Looks like | Sent as | Best for |
|---|---|---|---|
| Interactive JWT | a short-lived bearer token | Authorization: Bearer <jwt> | interactive / first-party clients (the web app) |
| Scoped API key | kenly_sk_live_… | Authorization: Bearer <key> or X-Api-Key: <key> | non-interactive agents, MCP clients, servers |
A JWT (JSON Web Token) is the short-lived token you get back when you log in — great for a human session. An API key is a long-lived secret you create once and hand to a program.
You never have to tell the server which kind you're sending. A "smart" auth scheme looks at the shape of the credential and picks the right handler, so an API key works in the Authorization: Bearer slot exactly like a JWT does. Both credentials are treated the same way once you're in: everything is owner-scoped, meaning you only ever see your own data. A resource that isn't yours comes back as a 404 — the same as one that doesn't exist — so the API never even reveals that someone else's resource is there. That holds identically for a JWT and a key.
Interactive JWT
Log in and you get a bearer token that's good for 12 hours. Get one from POST /v1/auth/login (or POST /v1/auth/register if you're creating the account):
bash
curl -X POST https://api.getkenly.com/v1/auth/login \
-H "Content-Type: application/json" \
-d '{ "email": "you@example.com", "password": "..." }'javascript
const res = await fetch("https://api.getkenly.com/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "you@example.com", password: "..." }),
});
const { token } = await res.json();python
import requests
token = requests.post(
"https://api.getkenly.com/v1/auth/login",
json={"email": "you@example.com", "password": "..."},
).json()["token"]The response is { token, user }. Send that token as Authorization: Bearer <token> on your next calls. Wrong email or password comes back as 401 invalid_credentials. (You can also sign in with a Google or Apple identity token via POST /v1/auth/oauth/{provider}, which returns the same { token, user } shape.)
A JWT is ideal for a person clicking around the web app. For a program that runs on its own, use an API key instead — otherwise you'd have to log in again every 12 hours.
Scoped API key
An API key is a long-lived, revocable secret meant for agents and servers. You can send it either of two ways, and both authenticate exactly the same:
bash
# Both of these authenticate identically:
curl https://api.getkenly.com/v1/wallet -H "Authorization: Bearer kenly_sk_live_..."
curl https://api.getkenly.com/v1/wallet -H "X-Api-Key: kenly_sk_live_..."Two things worth knowing about how keys are kept safe:
- We never store the raw key. Only a SHA-256 hash of the secret is saved — the raw
kenly_sk_live_…value is never stored or written to a log. We keep the non-secretprefixso you can recognize a key in a list and so we can scan for leaked keys. - You see the secret once. The full secret is returned exactly once, at the moment you create the key. There's no way to look it up again — if you lose it, revoke that key and make a new one.
Scopes
A scope is a label on a key that says what it's allowed to do. A JWT (a human) can do everything; a key can do only what you granted it when you made it.
| Scope | Grants |
|---|---|
projects:write (default) | read and write — create projects, start processing, download, wallet |
projects:read | read-only access |
approve | required to approve/reject a reviewed page |
Two failures are worth telling apart:
- A valid key that's missing a scope the call needs is rejected with
403("you're who you say you are, but you're not allowed to do this"). - A key that's been revoked or has expired is a
401— the same status as an unknown credential, but it lets a client tell "bad key" from "key was turned off".
Give a key the least it needs. A dashboard that only reads data should carry projects:read, not the default projects:write — so if it ever leaks, it can't spend or change anything.
Generate a key in the web app
The self-service way:
- Sign in to the web app and open Settings → Developer — https://app.getkenly.com/settings/developer.
- Click Create key. Give it a descriptive name (e.g.
ci-pipelineormy-laptop), choose its scopes, and optionally set an expiry in days. - Copy the
kenly_sk_live_…secret now — it's displayed exactly once. Store it in your secret manager or an environment variable; never commit it. - Use it as
Authorization: Bearer <key>orX-Api-Key: <key>. Revoke it any time from the same screen — revocation is immediate.
Manage keys programmatically
You can also create and manage keys over the API instead of clicking through the web app — handy for automation. The endpoints live under /v1/api-keys and are owner-scoped (you only see your own). To manage keys this way you need an existing credential first: a JWT, or another key that has projects:write.
| Operation | Route | Returns |
|---|---|---|
| Create | POST /v1/api-keys | { id, name, prefix, scopes, createdUtc, expiresUtc, secret } — secret shown once |
| List | GET /v1/api-keys | [{ id, name, prefix, scopes, createdUtc, lastUsedUtc, expiresUtc, revokedUtc }] — never the secret |
| Revoke | DELETE /v1/api-keys/{id} | 204; revocation is immediate |
bash
# Create a read-only key that expires in 90 days (authenticated with an existing key or JWT):
curl -X POST https://api.getkenly.com/v1/api-keys \
-H "Authorization: Bearer $KENLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "reporting", "scopes": ["projects:read"], "expiresInDays": 90 }'javascript
const res = await fetch("https://api.getkenly.com/v1/api-keys", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.KENLY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "reporting", scopes: ["projects:read"], expiresInDays: 90 }),
});
const created = await res.json();
console.log(created.secret); // the only time you'll see itpython
import os, requests
created = requests.post(
"https://api.getkenly.com/v1/api-keys",
headers={"Authorization": f"Bearer {os.environ['KENLY_API_KEY']}"},
json={"name": "reporting", "scopes": ["projects:read"], "expiresInDays": 90},
).json()
print(created["secret"]) # shown exactly onceSource of truth: the authentication contract is single-sourced in the repo at
docs/api-contract.md§7 (JWT + API keys) and §7a (OAuth sign-in); this page renders it for developers.