Skip to Content
API ReferenceProjects & Sites

Projects

All endpoints require Authorization: Bearer TOKEN.

Create Project

POST /api/v1/projects

Body:

{ "name": "my-app", "framework": "nextjs", "plan": "hobby", "billing_account_id": "ba_abc123" }
FieldTypeDescription
namestringRequired. Project name, max 50 chars
frameworkstring?nextjs, node, nuxt, react, vue, svelte, static, docker, python, go, php, ruby, rust, java, deno, elixir, or auto to let the platform decide
planstring?Plan slug (e.g. hobby, pro). Server default when omitted
billing_account_idstring?Billing account backing the project
billing_modestring?plans (default) or payg
team_idstring?Owning team

Build configuration (build_command, start_command, install_command, output_directory, port, dockerfile_path, builder) is not set here — it belongs to each site. Declare it in .ghayma.json and it is applied at deploy, or set it with Update Site Build Settings. The project’s main site inherits the framework sent here when it is created.

Response: 201

{ "id": "prj_abc123", "name": "my-app", "framework": "nextjs", "status": "created", "url": "my-app.ghayma.app", "created_at": "2025-01-15T10:30:00Z" }

List Projects

GET /api/v1/projects

Response: 200 — Array of projects.

Get Project

GET /api/v1/projects/:id

Update Project

There is no generic PUT /api/v1/projects/:id. Each mutable aspect of a project has its own route.

Rename Project

POST /api/v1/projects/:id/rename

Body:

{ "new_name": "my-renamed-app" }

The slug and the default <slug>.ghayma.app domain are regenerated from the new name. Requires the admin or owner role on the project.

Every site of the project moves with it — a project rename shifts each site’s platform host, main to <new-slug>.ghayma.app and every other site to <new-slug>-<site-slug>.ghayma.app.

Old URLs don’t die instantly. Each site’s previous .ghayma.app host is kept as a temporary redirect to its new one for 30 days, then removed by a daily sweep. Custom domains are not affected by a rename. See Grace redirects.

Update Build Settings (all sites)

PUT /api/v1/projects/:id/build-settings

Build settings belong to a site — use Update Site Build Settings for one app. This route is the bulk operation: it writes the project row and every site of the project, which is what you want only when they all build the same way.

Body: all fields optional — an empty string or 0 clears the override and returns that setting to auto-detection.

{ "framework": "nextjs", "install_command": "npm ci", "build_command": "npm run build", "start_command": "npm start", "output_directory": "dist", "port": 3000 }

Requires the admin or owner role.

Change Plan

PUT /api/v1/projects/:id/plan

Body:

{ "plan": "pro", "billing_mode": "plans", "billing_account_id": "ba_abc123" }

billing_mode is optional — omit it to keep the project’s current billing model and change only the plan.

An upgrade is immediate and pro-rated. Moving to a dearer plan applies at once and charges only the price difference for the days left in the project’s current month or year — on day 10 of a 30-day month, 2,500 DZD/mo to 10,000 DZD/mo costs (10,000 − 2,500) × 21/30 = 5,250 DZD, returned as charged_milli_dzd. If the balance can’t cover it the change is refused with 402 and the plan is left untouched.

A downgrade is scheduled. Moving to a cheaper plan applies at the start of the next billing period (the response carries scheduled and effective_at); the project keeps its current plan and points budget until then, and nothing is credited for the period already paid. A same-price switch costs nothing. See Billing, Cycles & Credits.

Preview either outcome — without changing anything:

GET /api/v1/projects/:id/plan-quote?billing_mode=plans&plan=pro

Answers the kind (upgrade, downgrade, switch, or no_op), charge_now_milli_dzd, effective_at (null means now), the period bounds, and balance_ok with any shortfall_milli_dzd. It runs the same computation as the change itself, so a quote never disagrees with the charge.

Change Billing Cycle

POST /api/v1/billing/change-cycle

Switches a project between monthly and yearly billing. It sits under /billing, so the token needs the billing scope, and the caller the admin or owner role on the project.

Body:

{ "project_id": "prj_abc123", "billing_cycle": "monthly" }
FieldTypeDescription
project_idstringRequired. The project to switch
billing_cyclestringRequired. monthly or yearly
  • Monthly → yearly charges the yearly price and credits the unused days of the current month. The year starts now and ends at next_renewal.
  • Yearly → monthly credits the unused part of the year: what was paid for the current year minus what the same time costs at the monthly price, counting the current month in full, never below 0. Monthly billing starts at covered_until, the 1st of next month. Yearly Credit Quote shows the amount first.

Credit goes to the project’s billing account balance; nothing is paid back to a card. Amounts are in milli-DZD (1 DZD = 1,000).

Response: 200 (yearly → monthly)

{ "old_cycle": "yearly", "new_cycle": "monthly", "credit_milli_dzd": 4000000, "charge_milli_dzd": 0, "covered_until": "2026-10-01T00:00:00Z", "new_balance_milli_dzd": 12500000 }
FieldTypeDescription
old_cycle, new_cyclestringThe cycle before and after
credit_milli_dzdnumberCredited to the billing account by this switch
charge_milli_dzdnumberCharged by this switch: the yearly price when moving to yearly, 0 when moving to monthly
covered_untilstring?Yearly → monthly: when monthly billing starts (RFC 3339, UTC)
next_renewalstring?Monthly → yearly: when the new year ends and renews
new_balance_milli_dzdnumberThe billing account balance after the switch
refund_milli_dzdnumberDeprecated. Same value as credit_milli_dzd, kept for older clients
refund_suppressedbooleanDeprecated. Always false

Errors

StatusWhen
400billing_cycle is not monthly or yearly, the project is already on that cycle, its plan has no yearly price, it is pay-as-you-go, or it has no billing account
403The caller is not an admin or owner of the project
404Project not found
429The cycle changed less than 7 days ago. The body carries last_change_at and next_allowed_at, with a Retry-After header

Yearly Credit Quote

GET /api/v1/projects/:id/yearly-credit-quote

What leaving the yearly cycle now would credit — by switching to monthly or by deleting the project — without changing anything. The same computation serves the switch and the delete, so the quote never disagrees with the credit. Requires the admin or owner role, like the plan quote.

Response: 200

{ "eligible": true, "credit_milli_dzd": 4000000, "paid_milli_dzd": 10000000, "used_cost_milli_dzd": 6000000, "period_start": "2026-04-01T00:00:00Z", "period_end": "2027-04-01T00:00:00Z", "covered_until": "2026-10-01T00:00:00Z", "plan": "hobby" }
FieldTypeDescription
eligiblebooleantrue for a project on a plan, billed yearly, with a billing account, inside its paid year. Otherwise credit_milli_dzd is 0
credit_milli_dzdnumberWhat would be credited: paid_milli_dzd − used_cost_milli_dzd, never below 0
paid_milli_dzdnumberCharged for the current year, upgrade charges included
used_cost_milli_dzdnumberWhat monthly billing would have charged from period_start to covered_until, each month at the monthly price of the plan in force that month
period_start, period_endstring?The current yearly period (RFC 3339, UTC); null when the project has none
covered_untilstring?The 1st of next month (UTC): the current month counts in full
planstringThe project’s plan

Enable a Custom Dockerfile

PUT /api/v1/projects/:id/custom-dockerfile

Body:

{ "enabled": true }

A project-level permission: when enabled, a Dockerfile in your upload is used instead of the auto-generated one, for every site that has one. When disabled, every site of the project gets a generated Dockerfile whatever its files or builder say. Opt a single site out with builder: "auto" in Update Site Build Settings. See Custom Dockerfile.

Delete Project

DELETE /api/v1/projects/:id

Requires the admin or owner role.

Deleting a project removes all deployments, domains, and linked resources.

Response: 200

{ "message": "project deleted", "credited_milli_dzd": 4000000 }

A project billed yearly gets the unused part of its year credited to its billing account, in the same step as the delete — the amount Yearly Credit Quote returns. credited_milli_dzd is 0 when nothing is owed: a project billed monthly (the current month is not credited), a free or pay-as-you-go project, or a year whose used months, at the monthly price, already cost as much as the year. If the credit cannot be recorded, the call answers 500 and the project stays listed so the delete can be retried; a credit is never written twice. See Billing, Cycles & Credits.

Get Project Logs

GET /api/v1/projects/:id/logs?lines=100
ParameterTypeDefaultDescription
linesnumber100Number of log lines to return

Get Project Metrics

GET /api/v1/projects/:id/metrics?period=24h
ParameterTypeDefaultOptions
periodstring24h1h, 24h, 7d, 30d

Response: 200

{ "cpu_usage": 0.15, "memory_usage": 128.5, "requests_total": 12450, "avg_latency_ms": 45 }

Sites

Sites allow multiple independently deployable apps within a single project, each with their own deployments, domains, and environment variables.

A project is created with no sites. The main site is materialised lazily by the first app action — the first deployment, or attaching a custom domain — and only on a project that has zero sites. It is not privileged after that: main can be renamed and deleted like any other site, and a project holding several custom sites with no main and no default is a legal steady state.

A route under /projects/:id/sites/:siteId answers 404 when :siteId is not a site of project :id, even if you can reach the project it belongs to.

Site URLs

Site slugHosted at
main<project-slug>.ghayma.app
anything else<project-slug>-<site-slug>.ghayma.app

Every site-returning endpoint computes this for you as default_domain — don’t re-derive it client-side.

The site object

FieldTypeDescription
idstringSite ID
project_idstringOwning project
namestringDisplay name
slugstringSlugified name; the site’s identity in URLs and infrastructure
default_domainstringComputed. The site’s hosted .ghayma.app URL, per the table above
is_defaultbooleanWhether implicit calls resolve to this site
statusstringinactive until the first successful deploy, then active
app_tier_slugstringCompute tier. Set at creation; empty only on a site created before sizing was asked for
replicasnumberReplica count. Set at creation; 0 only on a site created before sizing was asked for
build_settingsobjectThe site’s own build configuration — see Update Site Build Settings
rename_statusstring?Present only during or after a failed URL-moving rename: renaming or failed. Absent when idle
rename_errorstring?Present only when rename_status is failed
created_at / updated_atstringTimestamps

build_settings is returned by every site-returning endpoint; it is left out of the examples below for brevity.

Implicit site resolution

Endpoints that name a project but no site (deploys, Add Domain, project-level env vars) resolve the site through one fixed ladder:

  1. the site with is_default: true
  2. otherwise the site whose slug is main
  3. otherwise the only site, if the project has exactly one
  4. several sites, none marked → 409 this project has multiple sites — specify one or set a default site
  5. no sites at all → only an app action creates one (see below); every other caller fails

Rung 5 is the single point where a site is ever invented, and only app actions take it: a first deploy that names no site, and attaching a custom domain, both materialise main on a project with zero sites. Rung 4 never creates anything — an ambiguous project is a question for you, not a guess for the platform.

Connecting a new database, bucket or auth app does not use the ladder: it is connected only to the sites in its create’s connect_site_ids, and to none when that list is empty — even in a project with exactly one site. Creating a site, the first one included, connects nothing. See How a service gets connected.

List Sites

GET /api/v1/projects/:id/sites

Response: 200 — a bare array, oldest first.

[ { "id": "site_abc123", "project_id": "prj_abc123", "name": "main", "slug": "main", "default_domain": "my-app.ghayma.app", "is_default": true, "status": "active", "app_tier_slug": "b", "replicas": 1, "created_at": "2026-01-15T10:30:00Z", "updated_at": "2026-01-15T10:30:00Z" }, { "id": "site_def456", "project_id": "prj_abc123", "name": "admin", "slug": "admin", "default_domain": "my-app-admin.ghayma.app", "is_default": false, "status": "active", "app_tier_slug": "a", "replicas": 1, "created_at": "2026-02-01T09:00:00Z", "updated_at": "2026-02-01T09:00:00Z" } ]

Create Site

POST /api/v1/projects/:id/sites

Body:

{ "name": "admin", "environment": "production", "app_tier_slug": "b", "replicas": 2 }
FieldTypeDescription
namestringRequired. The slug is derived from it
environmentstring?development (default), staging or production
app_tier_slugstring?Compute tier to create the site on. Omitted → the plan’s default_app_tier
replicasint?Replica count, minimum 1. Omitted → the plan’s default_replicas

Size the site here, not afterwards. Creating first and resizing second reserves the default tier’s points first, so a failed second call leaves a wrongly-sized site the project is already charged for. Passing the size at create makes it one admission. The console’s New Site form asks for the tier for the same reason.

Read the plan’s defaults from GET /api/v1/marketplace/catalog — each entry in plans[] carries default_app_tier and default_replicas, which is exactly what the backend would pick:

{ "slug": "starter", "points": 20, "max_app_tier": "c", "max_db_tier": "m", "default_app_tier": "a", "default_replicas": 1 }

The slug is auto-generated from the name. A new site starts with no build settings — it auto-detects until you deploy a .ghayma.json or set them with Update Site Build Settings.

Response: 201 — the site object.

{ "id": "site_def456", "project_id": "prj_abc123", "name": "admin", "slug": "admin", "default_domain": "my-app-admin.ghayma.app", "is_default": false, "status": "inactive", "app_tier_slug": "b", "replicas": 2 }

Errors:

StatusCodeMeaning
400unknown_app_tierapp_tier_slug names no tier in the catalog. Refused before any points are reserved
400—replicas below 1
409—The tier is above the plan’s max_app_tier, or the project has not enough points for it
409duplicate_site_slugAnother site in this project already derives that slug
422site_name_too_longThe site’s Kubernetes namespace would exceed 63 characters
503—The platform has no capacity for that size right now

On a pay-as-you-go project the size is validated and recorded without a points admission — the wallet is the cap. See Points Troubleshooting for the recovery paths behind the 409 and 503.

Get Site

GET /api/v1/projects/:id/sites/:siteId

Response: 200 — the site object. This is also the endpoint you poll while a rename is running.

Update Site

PUT /api/v1/projects/:id/sites/:siteId

Body:

{ "name": "public-api", "acknowledge_url_change": true }
FieldTypeDescription
namestringRequired. New display name
acknowledge_url_changeboolean?Required only when the new name derives a different slug

Rename has two tiers, decided by the slug the new name produces.

Tier 1 — same slug. A display-name edit ("api" → "API"). Nothing moves. Synchronous 200 with the updated site object. acknowledge_url_change is not needed.

Tier 2 — different slug. The slug is the site’s identity: its URL, its Kubernetes resources, its image repository and its network policies all carry it. Renaming therefore moves the site’s URL, so it must be acknowledged and then runs asynchronously.

Without acknowledge_url_change, the request is refused with 400:

{ "code": "acknowledge_url_change_required", "old_url": "my-app.ghayma.app", "new_url": "my-app-public-api.ghayma.app", "grace_days": 30, "renaming_main": true, "error": "renaming this site moves its URL from … — it gives up the project's main URL; the old URL redirects for 30 days, then stops working. Resend with {\"acknowledge_url_change\": true} to proceed" }

renaming_main is true when the site currently is main — it is giving up the project’s bare URL, the case worth surfacing loudly.

With the acknowledgement, the migration is accepted with 202:

{ "rename_status": "renaming", "old_url": "my-app.ghayma.app", "new_url": "my-app-public-api.ghayma.app", "grace_days": 30 }

Then poll Get Site until rename_status disappears (success — slug and default_domain now hold the new values) or becomes failed, in which case rename_error says why and the site is untouched on its old slug.

Renaming to main claims the project’s bare URL; renaming away from main gives it up. Both work.

Errors:

StatusCodeMeaning
400acknowledge_url_change_requiredSlug-changing rename without the acknowledgement
409duplicate_site_slugAnother site in this project already derives that slug
409target_url_takenThe URL that name would produce belongs to another project, or is live on a sibling site
409target_namespace_takenThe Kubernetes namespace that name would produce belongs to another project
409rename_in_progressA rename or a deployment is already running on this site, or the project itself is being renamed
422site_name_too_longThe site’s Kubernetes namespace would exceed 63 characters

A same-project grace redirect holding the target URL is not a conflict — the migration reclaims it. That is what makes renaming back, or re-creating a site under its old name, work immediately.

Deploys and rollbacks are rejected while a rename runs, and a rename is rejected while a deployment is in flight.

Change Site Size

PUT /api/v1/projects/:id/sites/:siteId/tier

Body:

{ "app_tier_slug": "b", "replicas": 2 }
FieldTypeDescription
app_tier_slugstringRequired. The new compute tier
replicasintRequired. The new replica count, minimum 1

The change is admitted like a create: a bigger size needs the points and must stay within the plan’s max_app_tier, and a smaller one returns the difference.

The running app changes right away. The new size is saved, then applied to the running app with a rolling update: a pod with the new size starts and takes traffic before an old one stops, so the app stays up. A replica change alone adds or removes pods without restarting the others.

A smaller tier must fit the app’s memory. When the new tier has less memory than the app has now, the platform reads the most memory any of the app’s pods used over the last 7 days. The tier is refused unless that peak fits in 90 % of the new tier’s memory, since the smaller app would otherwise run out of memory and be restarted. An app with no memory use recorded yet (never deployed, or not running) is not checked. CPU is not checked: a smaller CPU limit slows the app down, it does not stop it.

Response: 200 — the site object, plus:

FieldTypeDescription
appliedbooleantrue when the running app is moving to the new size now
apply_notestring?Present when applied is false: why, and when the new size takes effect
{ "id": "site_def456", "name": "admin", "slug": "admin", "status": "active", "app_tier_slug": "b", "replicas": 2, "applied": true }

applied is false when the site has never been deployed ("the site has not been deployed yet; its first deploy uses the new size"), or when the running app could not be changed at that moment. The new size is saved either way, and the next deploy uses it.

Errors:

StatusCodeMeaning
400—replicas below 1
409tier_too_smallThe app’s memory use over the last 7 days does not fit the smaller tier. Nothing is saved
409—The tier is above the plan’s max_app_tier, or the project has not enough points for it
503metrics_unavailableThe app’s memory use could not be read, so a smaller tier cannot be checked. Nothing is saved; try again shortly
503—The platform has no capacity for that size right now

A tier_too_small refusal carries the peak and the smallest tier the app fits. min_app_tier is empty when no tier your plan allows fits it:

{ "code": "tier_too_small", "error": "the app used up to 615 MB of memory in the last 7 days, which does not fit tier a with room to spare; the smallest tier it fits is b", "peak_memory_bytes": 645283840, "min_app_tier": "b" }

On a pay-as-you-go project the size is saved without a points admission or a memory check, and it takes effect at the next deploy (applied: false).

Delete Site

DELETE /api/v1/projects/:id/sites/:siteId

Body: optional — { "confirm": true }.

Any site can be deleted, including main and including the project’s default site. Deleting the last site returns the project to its site-less state; the next deploy re-creates main.

Response: 200 — { "message": "site deleted" }.

One transaction removes the site and everything keyed on it:

  • deployments, environment variables, and every domain attached to the site (including its default .ghayma.app URL)
  • its cron jobs and their run history
  • its connections to databases, buckets and auth apps

Purchased-domain records are kept — they are receipts — and merely unlinked from the deleted site. Because points price what you actually run, dropping the site’s app and cron jobs releases their points; there is no separate refund.

Deleting the main or default site requires {"confirm": true} in the request body. Without it the call is refused with 400 and a confirm_required payload listing exactly what stops working. Deleting any other site needs no body.

Response: 400 when confirmation is missing:

{ "code": "confirm_required", "site_slug": "main", "is_main": true, "is_default": true, "implications": [ "this is the project's main site: its default <project>.ghayma.app URL and every custom domain attached to it stop serving", "this is the project's default site; after deletion the project has no default until you set one", "the running app, its deployment history and its environment variables are removed", "the site's cron jobs and their run history are removed", "the site's connections to databases, buckets and auth apps are removed" ], "error": "deleting \"main\" removes the project's main and default site — resend with {\"confirm\": true} to proceed: …" }

Render implications rather than parsing error.

Errors:

StatusCodeMeaning
400confirm_requiredMain or default site, and confirm was missing or false
409deploy_in_flightA queued, building or deploying deployment is running on this site
409rename_in_progressThe project is being renamed
409site_rename_in_progressThis site is being renamed

Set Default Site

POST /api/v1/projects/:id/sites/:siteId/set-default

Marks this site as the project’s default — rung 1 of implicit site resolution. Set one whenever a project has several sites and no main, otherwise every call that doesn’t name a site is refused.

Response: 200 — the site object, now with is_default: true.

Update Site Build Settings

PUT /api/v1/projects/:id/sites/:siteId/build-settings

How this site builds. Every site owns its eight build fields, so two sites in one project can run different frameworks, commands and ports.

Body: all fields optional — an empty string or 0 clears the setting and returns it to auto-detection.

{ "framework": "nextjs", "install_command": "npm ci", "build_command": "npm run build", "start_command": "npm start", "output_directory": "dist", "port": 3000, "dockerfile_path": "infra/Dockerfile.production", "builder": "" }
FieldDescription
dockerfile_pathPath to a custom Dockerfile, relative to the app’s directory (root_directory)
builder"" (default) — use a Dockerfile when the project has custom Dockerfiles enabled and this site has one. "auto" — always generate the Dockerfile for this site and ignore any Dockerfile

Requires the admin or owner role. These are the same values .ghayma.json carries, and a deploy writes back every build field it finds there onto the site it deployed — so a saved setting is the standing default until you change it here. Removing a key from .ghayma.json does not clear it; send an empty value.

Errors:

StatusCodeMeaning
400—builder is not "" or "auto", or port is out of range
409site_rename_in_progressThis site is being renamed

Get Site Env Vars

GET /api/v1/projects/:id/sites/:siteId/env

Response: 200

{ "env_vars": { "API_KEY": "sk-123", "DATABASE_URL": "postgresql://..." } }

Set Site Env Vars

Replaces all env vars for the site in one call.

PUT /api/v1/projects/:id/sites/:siteId/env

Body:

{ "env_vars": "{\"API_KEY\":\"sk-123\",\"DATABASE_URL\":\"postgresql://...\"}" }

Note: env_vars is a JSON-encoded string (double-serialised for legacy compatibility).

Get Site Effective Env Vars

GET /api/v1/projects/:id/sites/:siteId/env/effective

The exact environment a pod of this site receives: the site’s stored variables plus every value the platform derives from the site’s connections — database URLs, the STORAGE_* set, the GHAYMA_AUTH_* variables, the cron signing secrets, and the managed GHAYMA_API_KEY. It is what ghayma env pull writes locally. The derivation is the same one a deploy runs, so the answer matches the running pod.

Response: 200

{ "env": { "API_KEY": "sk-123", "DATABASE_URL": "postgresql://...", "GHAYMA_API_KEY": "gsk_..." } }

env is always an object — {} when the site has no variables at all.

Requires the admin or owner role. Reading stored variables needs only the read role; every value here is a credential of a connected service, so the bar sits higher. Each successful read is recorded in the project’s audit log like a credentials reveal: who, which site, which project, the caller’s IP and user agent, and the number of variables — never the values, never their names.

Errors:

StatusMeaning
403insufficient role — the caller is below admin on the project
404The project is not visible to the caller, or the site is not in that project
503The platform’s build worker is still starting, so the environment cannot be derived yet — retry in a moment

Not available to project API keys. The route is absent from the key allowlist, which denies by default: a key is minted from a site’s connections, so it must not be able to read every secret behind them. Call this with an account token or a dashboard session, and treat the response as secrets — it carries live credentials.

Open a Tunnel Session

POST /api/v1/projects/:id/sites/:siteId/tunnel-sessions

Opens a tunnel session for the site: a short-lived grant that lets a developer’s machine reach the databases the site is connected to, through the platform’s tunnel gateway, without exposing anything publicly. It is what ghayma connect --local calls. The body is empty.

The session’s targets are the site’s connected databases that are running — buckets and auth apps are absent, because they are public HTTPS with credentials and need no tunnel.

Response: 201

{ "token": "…", "expires_at": "2026-09-14T06:00:00Z", "gateway_url": "wss://tunnel.ghayma.tech", "targets": [ { "id": "…", "kind": "postgres", "name": "my-postgres", "host": "…", "port": 5432 } ] }

A target’s kind is its engine: postgres, mongodb or valkey.

token is the bearer token the gateway accepts, and the only time it exists outside the caller’s memory — the platform stores and logs the hash of it, never the token itself. Present it to the gateway as GET <gateway_url>/v1/dial?target=<id> with Authorization: Bearer <token>; the gateway dials only a target the session already lists, so a caller chooses an id, never an address. targets is always an array — [] never occurs, since a session with nothing to reach is refused.

Sessions expire 8 hours after they are opened, whether or not they are closed. Requires the admin or owner role: the tunnel reaches the databases with the app’s own credentials, the same bar as the effective environment above. Opening is recorded in the project’s audit log — who, which site, which project, how many targets, the caller’s IP and user agent, and the session’s short id, never the token.

Errors:

StatusMeaning
403insufficient role — the caller is below admin on the project
404The project is not visible to the caller, or the site is not in that project
409The site is connected to no database, so a tunnel would have nothing to reach

Close a Tunnel Session

POST /api/v1/projects/:id/sites/:siteId/tunnel-sessions/close

Revokes a session before it expires. ghayma connect --local calls it on the way out, so a tunnel you stopped is not left dialable until its 8 hours run out.

Body:

{ "token": "…" }

Response: 200

{ "closed": true }

closed is false — still a 200 — when the token names no live session of yours on this project and site: a retry after a lost response, a session that already expired, a session opened on another project or site, or a token that was never ours all mean the same thing to the caller, and answering 404 would confirm which tokens are live. Closing is audited like opening.

Not available to project API keys. Both routes are absent from the key allowlist, which denies by default: a key is minted from a site’s connections, so a key that could open a raw TCP path to those databases would widen its own reach. Call these with an account token or a dashboard session.