Skip to main content

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"}'
201 Created
{
"apiKey": "…",
"hint": "a1b2",
"description": "nightly deploy",
"createdAt": "2026-08-14T09:00:00Z",
"expiresAt": "2026-09-14T09:00:00Z"
}
This is the only time the key exists anywhere readable

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

expiryKey expires
OneHour1 hour (the default if omitted)
OneDay1 day
OneMonth1 month
OneYear1 year
NoExpiryNever — 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

EndpointShowsPermission
GET /users/me/apikeysYour own keysJWT only
GET /users/{id}/apikeysAnother account's keysUserRead

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

EndpointPermissionNotes
DELETE /users/me/apikeys/{id}JWT onlyYour own key
DELETE /users/{id}/apikeys/{apiKeyId}UserDeleteSomebody 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. NoExpiry exists 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 VariableRead and little else.