API keys
An API key is how a machine caller — a deployment script, a scheduled job, an integration — comes by a session, since it has no browser to be sent round an identity provider with.
Creating a key
A key is always minted for whoever asks for it. Whose key it is comes from the JWT and never from the request, so this cannot be used to mint a key for somebody else.
curl -X POST "$ACCOUNT_API/users/me/apikeys" \
-H "x-tagshape-auth: $JWT" \
-H "Content-Type: application/json" \
-d '{"description": "nightly deploy", "expiry": "OneMonth"}'
{
"apiKey": "…",
"hint": "a1b2",
"description": "nightly deploy",
"createdAt": "2026-08-14T09:00:00Z",
"expiresAt": "2026-09-14T09:00:00Z"
}
What is stored is a hash. apiKey cannot be asked for again — not by you, not by an administrator, not from the
database. Lose it and the only remedy is to delete the key and make another.
Lifetimes
expiry | Key expires |
|---|---|
OneHour | 1 hour (the default if omitted) |
OneDay | 1 day |
OneMonth | 1 month |
OneYear | 1 year |
NoExpiry | Never — needs ProfileNeverExpiresApiKey |
The caller names a lifetime and the date is worked out server-side, so the clock that sets the deadline is the same one that later decides whether the key has passed it.
NoExpiry is a separate permission because a key that never expires is one that cannot be forgotten about
safely. ProfileNeverExpiresApiKey depends on ProfileCreateApiKey — it is meaningless without it.
Asking for NoExpiry without holding it is refused with 400 Bad Request, not 403. The caller may create
keys; what is wrong is the lifetime they asked for, not the request itself. Unusually, the message names the
missing permission — the caller demonstrably does not hold it, so there is nothing to give away, and knowing is
the only way they can go and ask for it.
Using a key
Exchange it for a JWT, then use the JWT:
JWT=$(curl -s -i -X POST "$IDENTITY_API/apikeyjwt" \
-H "x-tagshape-api: $API_KEY" \
| grep -i '^x-tagshape-auth:' | cut -d' ' -f2 | tr -d '\r')
curl "$LIBRARY_API/variablevalues?set=1&tag=Environment/Production" \
-H "x-tagshape-auth: $JWT"
The key travels in a header rather than a body so the exchange looks like every other call the caller makes, and the token comes back in one for the same reason.
The token carries the same claims a signed-in person's token would — same sub, same org, same permissions,
same lifetime. Nothing downstream has to know a key was involved.
A missing key and a bad key both answer 401 Unauthorized. Anything else would tell whoever is probing that the
header is at least the right one to be guessing at.
If signing itself fails the answer is 500, not 401 — saying 401 would send somebody off rotating a key that
was fine all along.
Listing keys
| Endpoint | Shows | Permission |
|---|---|---|
GET /users/me/apikeys | Your own keys | JWT only |
GET /users/{id}/apikeys | Another account's keys | UserRead |
Both return hints and dates only — never the hash, and never the key, which no longer exists anywhere to be returned.
[
{
"id": "…",
"hint": "a1b2",
"description": "nightly deploy",
"createdAt": "2026-08-14T09:00:00Z",
"expiresAt": "2026-09-14T09:00:00Z"
}
]
Newest first, because the one somebody is looking for is nearly always the one they just made.
Revoking
| Endpoint | Permission | Notes |
|---|---|---|
DELETE /users/me/apikeys/{id} | JWT only | Your own key |
DELETE /users/{id}/apikeys/{apiKeyId} | UserDelete | Somebody else's |
Revoking your own key asks for no permission — not even the one that let you create it. Being able to stop
your own credential working is not a privilege somebody else grants you, and whoever loses ProfileCreateApiKey
can still clean up after themselves.
Revoking somebody else's asks for UserDelete. Taking a working credential off an account is the same kind of
act as removing the account itself.
Keys are removed outright rather than stamped as expired. Expiring one would stop it working just as surely, but it would stay on the account's list for ever, and a list nobody can tidy is a list nobody reads. The audit entry keeps the record of it having existed.
How a key resolves to an account
POST /apikeys/lookup on the account API takes the SHA of a key and returns the account holding it, with the
permissions from its groups. It is anonymous, because it is asked before the caller has a token and is part of how
they come to have one.
A key nobody holds, a key that has been revoked, and a key that has expired all get the same 404. Nothing tells
whoever is asking which of their guesses was once a real key.
Uniqueness is checked across every key in the system, not just the organization's. A key is presented on its own and the account is found by looking its hash up, so two accounts sharing one would leave that lookup with two answers and no way to choose.
Practical guidance
- Give every key a description. Hints are four characters; the description is how you tell one key from another when it is time to rotate.
- Prefer a lifetime.
NoExpiryexists for the cases that genuinely need it, and is gated for a reason. - Exchange once per run, not per request. The resulting JWT is good for its full duration.
- Grant keys the same way you grant people. A key carries the permissions of the account that owns it, so a
deployment key should belong to an account in a group holding
VariableReadand little else.