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.

Creating a key for a service account​

A service account has no login, so it has no session in which to ask for its own key. Somebody else makes it:

curl -X POST "$ACCOUNT_API/users/$SERVICE_ACCOUNT_ID/apikeys" \
-H "x-tagshape-auth: $JWT" \
-H "Content-Type: application/json" \
-d '{"description": "nightly deploy", "expiry": "OneMonth"}'

The request body and the 201 Created response are the same as above. The differences are in what is asked of the caller:

  • UserCreateServiceAccountApiKey is required, and it depends on UserRead, so both are checked. It is kept apart from UserCreate and UserUpdate because a key is a standing credential that acts as the account.
  • ProfileNeverExpiresApiKey is still required for NoExpiry, and is checked against the caller — the person making the key — not against the service account.
  • The account must be a service account. A person's key acts as that person and is theirs to make, so asking for one for a person is refused with 400 Bad Request, whatever the caller holds.
  • The account must be in the caller's organization; an ID from another one reads as 404 Not Found.

The key belongs to the service account from then on and carries the permissions of its groups, not the caller's.

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.