Skip to Content
API ReferenceAccount Tokens

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" }
FieldTypeMeaning
idstringRow UUID. This is what the rotate, revoke and activity routes take
namestringHow you recognize it. Up to 100 characters
token_prefixstringThe first 10 characters of the secret, so you can tell rows apart
scopestringThe scope string as you sent it — full, or a comma-separated list
scopesstring[]The same scope as a list: full names every scope, and the legacy deploy answers ["projects","deploy"]
project_idsstring[]The projects a restricted token may act in. Empty on an unrestricted token
projectsobject[]The same projects as {id, slug}, so you can read them without a lookup
expires_atstring?When it stops working. null when it never expires
last_used_atstring?Last authenticated request, written at most once a minute per token. null until first use
revoked_atstring?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/tokens

Body:

{ "name": "throct-crm", "scope": "projects,deploy,databases", "expires_in_days": 90, "project_ids": ["acme"] }
FieldTypeDescription
namestringRequired. Up to 100 characters
scopestring?full, or a comma-separated list from Scopes. Defaults to the legacy deploy, which expands to projects,deploy
expires_in_daysint?Lifetime in days. Omit it — or send 0 — for a token that never expires
project_idsstring[]?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:

StatusCodeMeaning
400unknown_projectA project_ids entry is not a project you can access
403scope_exceeds_tokenA token asked for a scope it does not hold itself — see No wider than itself
403projects_exceed_tokenA restricted token asked for a project outside its own list

List Tokens

GET /api/v1/tokens GET /api/v1/tokens?include_revoked=1

Returns 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/rotate

Mints 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:

StatusCodeMeaning
404—token not found — unknown id, a malformed id, or another user’s token
409token_revokedThe 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/:id

A 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”.

QueryDefaultNotes
limit50Capped 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/scopes

The 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" } ] }
ScopeOpens
projectsProjects, sites, env vars, logs, metrics
deployDeployments, rollback, upload
databasesDatabase CRUD, credentials, query, backups
storageStorage CRUD, objects, credentials
domainsDomain CRUD, DNS check
auth-appsAuth app CRUD, user management
teamsTeam CRUD, members, invites
billingBilling status, upgrade, payments, usage
tokensToken management — the routes on this page
accountAccount 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_id is in the list.

  • Outside the list — a project, or a resource of a project, that is not in the list answers 404 project 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, /storage and /auth-apps return 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 (full only from a full token), else 403 scope_exceeds_token. A restricted token can only mint tokens restricted to a subset of its own projects, else 403 projects_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 404 token 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, plus domains if it manages domains. Leave out tokens, billing, teams and account unless the system really drives them.
  • Set an expiry. expires_in_days costs 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.