Skip to main content
Version: 1.4

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 SERVICE with 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 — needs editor instead; one that only reads needs viewer. 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_admin for 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 403 and 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 }
}
  • error identifies the rule that refused. Today: template_in_use, already_exists (with a resource arg), 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 — with resource, pattern and maxLength in args), ingress_host_taken (another instance already serves an ingress hostname this deploy would claim — with host and the holder's heldBy namespace), 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_invalid and verdict_invalid (what an instance's purpose or a check or verdict may say — each carrying the reason in args), plus the licence refusals. instance_state covers the workflow writes too: a check or a verdict is refused unless the instance is running and its newest deploy attempt ended RUNNING. Deploying onto an elastic runtime adds three more: machine_profile_unknown (the environment asks for a machine size that runtime does not define — known lists the ones it does), machine_not_released (a previous machine of this instance is still being destroyed; deploy again shortly) and machine_not_ready (the instance's machine cannot be reached, so logs and a live log export have nothing to read — state is the machine's own state, or none). Administering one has its own codes, on admin calls only: provisioner_unknown, provisioner_config_invalid, machine_profile_invalid, and machine_list_unavailable on a reclaim whose provider would not say what it is holding — nothing could be checked, so nothing was destroyed, and reason carries 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 — are claim_rule_invalid, with the rule's index, its claim and the reason. More refusals gain codes over time.
  • message is always present and always readable, so a client that knows no codes loses nothing by ignoring error entirely.
  • args carries 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.