Account Tokens
An account token (gh_…) is your own long-lived credential for the API. It acts
as you — every project you can reach plus your account routes — within the
scopes you gave it, or only inside the projects you restrict it
to. Create, rotate and revoke them in Settings → API Tokens
in the console , or with the routes below.
All endpoints require Authorization: Bearer TOKEN. A token calling them needs
the tokens scope; a dashboard session always may. A project API
key (gsk_…) answers 403 on every route on this page — a
key is never granted the tokens scope, so it can neither read nor mint the
credential that outranks it.
Account token or project API key? A token acts as you across everything; a project key belongs to one project and reaches a deny-by-default allowlist of that project’s application routes. See Account tokens vs project keys.
The token object
{
"id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"user_id": "11111111-2222-4333-8444-555555555555",
"name": "ci",
"token_prefix": "gh_1a2b3c4",
"scope": "projects,deploy",
"scopes": ["projects", "deploy"],
"project_ids": ["cccccccc-dddd-4eee-8fff-000000000000"],
"projects": [{ "id": "cccccccc-dddd-4eee-8fff-000000000000", "slug": "acme" }],
"expires_at": "2027-09-23T10:00:00Z",
"last_used_at": "2026-09-23T11:59:00Z",
"revoked_at": null,
"created_at": "2026-09-01T09:00:00Z"
}| Field | Type | Meaning |
|---|---|---|
id | string | Row UUID. This is what the rotate, revoke and activity routes take |
name | string | How you recognize it. Up to 100 characters |
token_prefix | string | The first 10 characters of the secret, so you can tell rows apart |
scope | string | The scope string as you sent it — full, or a comma-separated list |
scopes | string[] | The same scope as a list: full names every scope, and the legacy deploy answers ["projects","deploy"] |
project_ids | string[] | The projects a restricted token may act in. Empty on an unrestricted token |
projects | object[] | The same projects as {id, slug}, so you can read them without a lookup |
expires_at | string? | When it stops working. null when it never expires |
last_used_at | string? | Last authenticated request, written at most once a minute per token. null until first use |
revoked_at | string? | When it was revoked. null while the token is live |
Every field is always present, null when unset, in the list and in the create
and rotate responses alike. The secret itself is stored only as a hash and is
shown once, when the token is created or rotated. There is no route that
returns it again.
An account holds at most 10 live tokens. Revoked rows do not count towards that ceiling.
Create a Token
POST /api/v1/tokensBody:
{
"name": "throct-crm",
"scope": "projects,deploy,databases",
"expires_in_days": 90,
"project_ids": ["acme"]
}| Field | Type | Description |
|---|---|---|
name | string | Required. Up to 100 characters |
scope | string? | full, or a comma-separated list from Scopes. Defaults to the legacy deploy, which expands to projects,deploy |
expires_in_days | int? | Lifetime in days. Omit it — or send 0 — for a token that never expires |
project_ids | string[]? | Project ids or slugs to restrict the token to. Omit it for an unrestricted token. Each must be a project you can access |
Response: 201
{
"id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"name": "throct-crm",
"token": "gh_0f1e2d3c…",
"scope": "projects,deploy,databases",
"scopes": ["projects", "deploy", "databases"],
"project_ids": ["cccccccc-dddd-4eee-8fff-000000000000"],
"projects": [{ "id": "cccccccc-dddd-4eee-8fff-000000000000", "slug": "acme" }],
"expires_at": "2026-12-22T10:00:00Z",
"message": "Save this token now — it won't be shown again."
}The response is the whole token object plus token and
message, shortened above. token is the secret, and this is the only response
that carries it.
Errors: 400 for a missing or over-long name, an invalid scope, or an
account already at 10 live tokens. Also:
| Status | Code | Meaning |
|---|---|---|
400 | unknown_project | A project_ids entry is not a project you can access |
403 | scope_exceeds_token | A token asked for a scope it does not hold itself — see No wider than itself |
403 | projects_exceed_token | A restricted token asked for a project outside its own list |
List Tokens
GET /api/v1/tokens
GET /api/v1/tokens?include_revoked=1Returns an array of token objects, newest first. Revoked
tokens are hidden by default — pass include_revoked=1 (or =true) to
include them, each carrying its revoked_at.
expires_at, last_used_at and revoked_at are null when unset, never
missing.
Rotate a Token
POST /api/v1/tokens/:id/rotateMints a replacement and revokes the original in the same transaction: there is never a moment with two usable copies, or none.
Body (optional — an empty body is the normal case):
{ "expires_in_days": 90 }The replacement keeps the old token’s name, scope and project restriction. Its expiry is the old
one’s remaining lifetime — a rotation is a key change, not a renewal —
unless you name a new one with expires_in_days; a token that never expired
stays that way.
Response: 200 — the same shape as Create, with the new
secret in token:
{
"id": "dddddddd-eeee-4fff-8000-111111111111",
"name": "throct-crm",
"token": "gh_9a8b7c6d…",
"scope": "projects,deploy,databases",
"scopes": ["projects", "deploy", "databases"],
"expires_at": "2026-12-22T10:00:00Z",
"message": "Save this token now — it won't be shown again. The previous token is revoked."
}Errors:
| Status | Code | Meaning |
|---|---|---|
404 | — | token not found — unknown id, a malformed id, or another user’s token |
409 | token_revoked | The token is already revoked. Minting a replacement would quietly un-revoke it, so create a new token instead |
Rotating a token that sits at the 10-token ceiling is never refused: the revoke frees the slot before the replacement is minted.
The old secret stops working the moment the rotation returns. Rotate when you are ready to write the new value where the old one lives — unlike a project key roll, there is no overlap window.
Revoke a Token
DELETE /api/v1/tokens/:idA soft revoke. The token stops authenticating immediately, and the row survives so every audit entry it wrote keeps naming a credential you can still look up — including through Activity, which reads a revoked token perfectly well. That is exactly what you want after a leak.
Response: 200
{ "message": "token revoked" }Idempotent — revoking twice answers 200, not 404, so a client retrying
after a dropped response never sees an error for work it already did. An unknown
id, a malformed id and another user’s token all answer 404 token not found.
A revoked token is refused with the same invalid API token an unknown one
gets: saying “revoked” would confirm to whoever holds the leaked string that it
was once real.
Token Activity
GET /api/v1/tokens/:id/activity?limit=50&cursor=…What this token did. Every audit entry a token-authenticated request produces carries the token’s id, so this is the per-credential half of the audit trail — the answer to “what has my CI token been doing”.
| Query | Default | Notes |
|---|---|---|
limit | 50 | Capped at 200. Anything absent, unparseable or out of range falls back to the default rather than failing the read |
cursor | — | The next_cursor of the previous page, passed back verbatim |
Response: 200
{
"entries": [
{
"id": "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff",
"action": "project.created",
"object_type": "project",
"object_id": "cccccccc-dddd-4eee-8fff-000000000000",
"ip": "203.0.113.7",
"created_at": "2026-09-23T12:00:00Z",
"attributes": { "slug": "acme", "plan": "hobby" }
}
],
"next_cursor": "MjAyNi0wOS0yM1QxMjowMDowMFp8YmJiYmJi…"
}Entries come back newest first. attributes is an object, not a string of
JSON, and never holds secret material — names, slugs, plans, tiers,
environments. next_cursor is "" on the last page; the key is always present,
so its type never changes.
Paging is a keyset over (created_at, id), not an offset: the audit table is
append-only and read newest-first, so an offset would repeat rows whenever an
entry lands mid-pagination. Walk it by passing next_cursor back until it comes
back empty.
Errors: 400 invalid cursor for a cursor that did not come from this
endpoint. 404 token not found for an unknown id, a malformed id, or another
user’s token — a 403 would confirm the id exists.
Actions that write nothing to the trail write nothing here: a token that only reads will show an empty list. The empty state is not a sign the attribution is broken.
List Scopes
GET /api/v1/tokens/scopesThe scopes you may put in a token, with a one-line description each — the same list the console renders in its create form.
Response: 200
{
"scopes": [
{ "name": "projects", "description": "Projects, sites, env vars, logs, metrics" },
{ "name": "deploy", "description": "Deployments, rollback, upload" }
]
}| Scope | Opens |
|---|---|
projects | Projects, sites, env vars, logs, metrics |
deploy | Deployments, rollback, upload |
databases | Database CRUD, credentials, query, backups |
storage | Storage CRUD, objects, credentials |
domains | Domain CRUD, DNS check |
auth-apps | Auth app CRUD, user management |
teams | Team CRUD, members, invites |
billing | Billing status, upgrade, payments, usage |
tokens | Token management — the routes on this page |
account | Account info, deletion |
full grants all of them. The bare value deploy is a legacy shorthand that
expands to projects,deploy.
Restricted tokens
A token created with project_ids acts only inside those projects. Scopes
still apply on top: a restricted token needs the right scope and the right
project.
-
Inside the list — every project route works as it would for an unrestricted token: sites, env vars, deployments, domains, databases, storage buckets and auth apps of those projects, including creating a database, bucket, auth app or domain whose
project_idis in the list. -
Outside the list — a project, or a resource of a project, that is not in the list answers
404project not found, the same answer as a project you have no access to, so a restricted token cannot even learn which other projects exist. -
Account routes — creating a project, accepting a collaboration or a transfer, teams, billing, account settings and account-wide usage, the GitHub App installation routes, and domain search and purchases answer
403:{ "error": "this token is restricted to specific projects", "code": "token_restricted" } -
Lists are filtered —
GET /projects,/databases,/storageand/auth-appsreturn only what belongs to the listed projects.
Use one restricted token per system that works on a few projects — a client integration, a CI pipeline for one app — so a leak exposes only those projects.
Tokens can only mint tokens no wider than themselves
A token with the tokens scope can manage tokens, but never one wider than
itself. Dashboard sessions are not affected.
- Create — every scope you ask for must be one the calling token holds
(
fullonly from afulltoken), else403scope_exceeds_token. A restricted token can only mint tokens restricted to a subset of its own projects, else403projects_exceed_token. An unrestricted token can mint restricted or unrestricted tokens. - Rotate, revoke, activity — the target token must be no wider than the
calling token, in scope and in projects; otherwise the target answers
404token not found, as if it did not exist. A token can always revoke itself.
So a tokens-only token can no longer rotate your full CLI token and read its
new secret.
Tokens for automation
An orchestrator, a CI pipeline or any long-running system that drives Ghayma on your behalf uses an account token — that is what it is for, and it is not going away. Run it like a credential rather than a password you typed once:
- One token per system. Name it after where it lives (
throct-crm,github actions), so revoking one never takes down anything else. - Only its projects. If it works on a few projects, restrict
it to them with
project_ids. - Minimal scopes. Give it only what it calls:
projects,deploy,databases,storage,auth-apps, plusdomainsif it manages domains. Leave outtokens,billing,teamsandaccountunless the system really drives them. - Set an expiry.
expires_in_dayscosts nothing and turns a forgotten credential into one that lapses by itself. - Rotate on a schedule. Rotate writes the new secret where the old one lives and kills the old one in the same breath — a quarterly rotation is a two-line job.
- Check what it did. Activity is per-token, so you can read one system’s actions without wading through the whole account.
For an app’s own server code, prefer a project API key: it belongs to one project, so a leak exposes one project’s application surface instead of everything you can reach.
ghayma login mints one for you. The CLI creates a token named
ghayma-cli@<hostname> with full scope and a 365-day expiry, and saves
it to ~/.paas-cli.json. It is listed in Settings → API Tokens like any
other, and ghayma logout revokes it. See CLI
Reference. To create, list, revoke or rotate tokens from
the terminal, see ghayma token.