API keys — for CI, and for you
Anything that talks to Fadebox without a browser session authenticates with an API key
(Authorization: Bearer fbx_...). There are two kinds, and which you want depends on whether the
thing acting is a pipeline or a person.
- A service-account key is for CI. A service account is a user of origin
SERVICEwith roles like anyone else, but no password and no interactive sign-in; a project maintainer creates its keys on the project page (or via the API). Instances it deploys are owned by the account, not by whoever clicked the button. - A personal key is your own credential, for the CLI on your machine and for one-off scripts. You create it under Settings → API Keys, and it acts as you. See Your own keys.
Both are fbx_ tokens, both are shown exactly once, and both follow the same rule below.
The model
The account is the key's scope, the key is only the credential: a key acts with exactly the account's roles and project memberships, like a signed-in user.
- Creating a key from a project grants the account a project role there (default
deployer— the full instance lifecycle: create, deploy, stop, delete, status, logs). A pipeline that also writes the environment definition — pinning this build's image tags on it, say — needseditorinstead; one that only reads needsviewer. See who gets which role. - A maintainer may only issue or revoke keys for accounts whose entire authority they could have
granted a colleague — an account with a global role (say
template_adminfor a CI job that manages templates) or memberships in other projects is issued its keys by an admin. - Creating a key without naming an account uses a per-project default (
svc-<project>), auto-created on first use; naming an account shares it across keys and projects. - Instances created via a key are owned by the service account — every key of the account acts as the same creator, so a pipeline's cleanup job can stop what its deploy job started.
The token is shown exactly once at creation (only its hash is stored); revoke a key by deleting it, or disable the whole account by deactivating it.
Expiry
A key can be given a lifetime — 30 days, 90 days, a year — after which it stops authenticating. The key stays in the list, marked expired, so a pipeline that suddenly fails has a visible cause rather than a mystery.
A lifetime is a span, not a date on the calendar: a key made at 4pm for 30 days stops working at 4pm on the thirtieth day, wherever you and it happen to be. The list says how long each key has left — expires in about 1 month — with the exact moment on hover.
Expiry is optional, and CI keys default to not having one. A key sitting in a secret store that quietly lapses breaks a deploy for whoever happens to be on call, so committing to a rotation schedule should be a decision rather than a default. Personal keys default the other way, to 90 days — they live on laptops.
Your own keys
Settings → API Keys issues a key for your own account. It carries exactly what you carry: the same global roles, the same projects, the same everything. That is the whole model — there is no narrower key, and no way to make one do less than you can.
Three consequences worth knowing before you create one:
- Only you can issue one, and only you can revoke one. Not a project maintainer, not an admin. A key that acted as someone else would put their name on every request it made, so the route to issue one simply doesn't exist. What an admin can do is deactivate your account or force a password reset — either one refuses every key you hold, from the next request onwards, and both are reversible.
- A key cannot create another key. If you try, through either the personal or the project
route, you get a
403and a message saying to sign in. This is why a leaked token cannot quietly grow itself a replacement that survives revoking the original. - Group changes from your identity provider reach a key only after you next sign in. A key reads the roles stored in Fadebox, and those refresh at interactive sign-in. Anything changed in Fadebox itself — a revoked role, a deactivated account — applies on the very next request.
If you sign in through SSO you have no password, so a personal key is how you use the CLI at all.
Using the key
An AI agent is the third consumer, and the one that most wants a key of its own: point Claude Code or an IDE assistant at the MCP endpoint and it reads and operates your environments with exactly the authority the key's account has. Give each agent its own service account, and do not hand one a personal key.
The fadebox CLI is the intended client — it wraps the deploy loop, exits non-zero when a
deploy fails, and prints the instance URLs. The server hands the binary out itself, with no key
needed for that step, so a pipeline fetches it before its first command and always runs the
server's own version:
export FADEBOX_URL=https://fadebox.example.com
export FADEBOX_TOKEN=$CI_FADEBOX_KEY
export FADEBOX_PROJECT=demo FADEBOX_ENV=dev
curl -fsSL -o /usr/local/bin/fadebox "$FADEBOX_URL/cli/fadebox-linux-amd64"
chmod +x /usr/local/bin/fadebox
fadebox instance up pr-42 --wait --purpose "MR !42, discount codes"
fadebox instance check pr-42 e2e --state passed --summary "84 passed, 0 failed"
fadebox instance stop pr-42 --wait
Saying what the copy is for and what the suite found costs the pipeline two flags and is the difference between a list of slugs and a list somebody can read. The fuller version — the ingress cookie, the failure path, where the teardown belongs — is A pipeline.
The same thing over plain HTTP
The API is ordinary REST, so a pipeline that would rather not fetch a binary can do it with curl:
HOST=https://fadebox.example.com
AUTH="Authorization: Bearer $FADEBOX_API_KEY" # from your CI secret store
P=demo; E=dev; I=pr-42
# create + deploy an instance for this pipeline run
curl -sf -H "$AUTH" -H 'Content-Type: application/json' -d "{\"name\":\"$I\"}" \
"$HOST/api/projects/$P/environments/$E/instances"
curl -sf -X POST -H "$AUTH" "$HOST/api/projects/$P/environments/$E/instances/$I/deploy"
# wait until it is RUNNING (deploy is async — the call above returns 202 immediately)
until [ "$(curl -sf -H "$AUTH" "$HOST/api/projects/$P/environments/$E/instances/$I/status" \
| jq -r .status)" = RUNNING ]; do sleep 3; done
# on a gated instance, mint the cookie the test runner needs
curl -sf -X POST -H "$AUTH" "$HOST/api/projects/$P/environments/$E/instances/$I/access-token"
# … run the suite, then report what it found on what this deploy is running
curl -sf -X PUT -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"state":"PASSED","summary":"84 passed, 0 failed","url":"'"$CI_JOB_URL"'"}' \
"$HOST/api/projects/$P/environments/$E/instances/$I/checks/e2e"
# logs of one service, then tear the instance down again
curl -sf -H "$AUTH" "$HOST/api/projects/$P/environments/$E/instances/$I/logs?service=app&tail=200"
curl -sf -X POST -H "$AUTH" "$HOST/api/projects/$P/environments/$E/instances/$I/stop"
curl -sf -X DELETE -H "$AUTH" "$HOST/api/projects/$P/environments/$E/instances/$I"
A robust script must also treat a final ERROR status as failure instead of looping forever —
which is exactly what the CLI's --wait is. GET .../status reports status plus containers,
keyed by service — .containers.web.urls[0] is the preview URL of the web service.
The access-token call answers {"cookie": …, "expiresAt": …, "tokens": [{"host", "value"}]} —
one value per hostname the instance holds, which the runner sets as that cookie on that host. An
instance deployed open answers with an empty list rather than
an error, so the step needs no condition around it. The check lands on the
instance's running deploy, not on the instance, so a redeploy starts clean — see
Checks and the verdict.
Either way, RUNNING means what the template's
healthchecks make it mean: a service without
one counts as ready the moment it starts, so give anything the pipeline will immediately talk to a
healthcheck, or the wait can end before the app answers.
Where a key can reach
A key is scoped by its account, so it sees what that account's roles and memberships reach: the
project list, the cross-project overview and the template catalog all narrow to them, and a 403
means a missing role, the same as for a person. See Users, groups and roles.
When a call is refused
A refusal carries a machine-readable code alongside the sentence, so a script can branch on the reason instead of matching on English:
HTTP 409
{
"error": "template_in_use",
"message": "Template 'postgres' is used by 3 environments — remove it from them before deleting it",
"args": { "template": "postgres", "count": 3 }
}
erroridentifies the rule that refused. Today:template_in_use,already_exists(with aresourcearg),environment_blueprint,environment_has_instances,instance_state,invalid_name(a name that has to become part of a container or ingress name is not a lowercase slug — withresource,patternandmaxLengthinargs),ingress_host_taken(another instance already serves an ingress hostname this deploy would claim — withhostand the holder'sheldBynamespace),environment_protected(the environment is protected and the key's account is not a maintainer of the project, so it may not change the definition),purpose_invalid,check_invalidandverdict_invalid(what an instance's purpose or a check or verdict may say — each carrying thereasoninargs), plus the licence refusals.instance_statecovers the workflow writes too: a check or a verdict is refused unless the instance is running and its newest deploy attempt endedRUNNING. Deploying onto an elastic runtime adds three more:machine_profile_unknown(the environment asks for a machine size that runtime does not define —knownlists the ones it does),machine_not_released(a previous machine of this instance is still being destroyed; deploy again shortly) andmachine_not_ready(the instance's machine cannot be reached, so logs and a live log export have nothing to read —stateis the machine's own state, ornone). Administering one has its own codes, on admin calls only:provisioner_unknown,provisioner_config_invalid,machine_profile_invalid, andmachine_list_unavailableon a reclaim whose provider would not say what it is holding — nothing could be checked, so nothing was destroyed, andreasoncarries the provider's own words. See Setting up an elastic runtime. An OIDC provider's required claims that are malformed — a blank claim name, a rule with no values or a blank one — areclaim_rule_invalid, with the rule'sindex, itsclaimand thereason. More refusals gain codes over time.messageis always present and always readable, so a client that knows no codes loses nothing by ignoringerrorentirely.argscarries the values already in the message, split out — useful for building your own wording, and never worth parsing the sentence for.
Treat an unrecognised error as you would a bare status code: the message is the fallback, and
codes are added over time rather than renamed.