Projects
All endpoints require Authorization: Bearer TOKEN.
Create Project
POST /api/v1/projectsBody:
{
"name": "my-app",
"framework": "nextjs",
"plan": "hobby",
"billing_account_id": "ba_abc123"
}| Field | Type | Description |
|---|---|---|
name | string | Required. Project name, max 50 chars |
framework | string? | nextjs, node, nuxt, react, vue, svelte, static, docker, python, go, php, ruby, rust, java, deno, elixir, or auto to let the platform decide |
plan | string? | Plan slug (e.g. hobby, pro). Server default when omitted |
billing_account_id | string? | Billing account backing the project |
billing_mode | string? | plans (default) or payg |
team_id | string? | 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/projectsResponse: 200 — Array of projects.
Get Project
GET /api/v1/projects/:idUpdate 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/renameBody:
{ "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-settingsBuild 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/planBody:
{
"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=proAnswers 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-cycleSwitches 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" }| Field | Type | Description |
|---|---|---|
project_id | string | Required. The project to switch |
billing_cycle | string | Required. 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 atcovered_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
}| Field | Type | Description |
|---|---|---|
old_cycle, new_cycle | string | The cycle before and after |
credit_milli_dzd | number | Credited to the billing account by this switch |
charge_milli_dzd | number | Charged by this switch: the yearly price when moving to yearly, 0 when moving to monthly |
covered_until | string? | Yearly → monthly: when monthly billing starts (RFC 3339, UTC) |
next_renewal | string? | Monthly → yearly: when the new year ends and renews |
new_balance_milli_dzd | number | The billing account balance after the switch |
refund_milli_dzd | number | Deprecated. Same value as credit_milli_dzd, kept for older clients |
refund_suppressed | boolean | Deprecated. Always false |
Errors
| Status | When |
|---|---|
400 | billing_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 |
403 | The caller is not an admin or owner of the project |
404 | Project not found |
429 | The 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-quoteWhat 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"
}| Field | Type | Description |
|---|---|---|
eligible | boolean | true for a project on a plan, billed yearly, with a billing account, inside its paid year. Otherwise credit_milli_dzd is 0 |
credit_milli_dzd | number | What would be credited: paid_milli_dzd − used_cost_milli_dzd, never below 0 |
paid_milli_dzd | number | Charged for the current year, upgrade charges included |
used_cost_milli_dzd | number | What 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_end | string? | The current yearly period (RFC 3339, UTC); null when the project has none |
covered_until | string? | The 1st of next month (UTC): the current month counts in full |
plan | string | The project’s plan |
Enable a Custom Dockerfile
PUT /api/v1/projects/:id/custom-dockerfileBody:
{ "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/:idRequires 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| Parameter | Type | Default | Description |
|---|---|---|---|
lines | number | 100 | Number of log lines to return |
Get Project Metrics
GET /api/v1/projects/:id/metrics?period=24h| Parameter | Type | Default | Options |
|---|---|---|---|
period | string | 24h | 1h, 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 slug | Hosted 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
| Field | Type | Description |
|---|---|---|
id | string | Site ID |
project_id | string | Owning project |
name | string | Display name |
slug | string | Slugified name; the site’s identity in URLs and infrastructure |
default_domain | string | Computed. The site’s hosted .ghayma.app URL, per the table above |
is_default | boolean | Whether implicit calls resolve to this site |
status | string | inactive until the first successful deploy, then active |
app_tier_slug | string | Compute tier. Set at creation; empty only on a site created before sizing was asked for |
replicas | number | Replica count. Set at creation; 0 only on a site created before sizing was asked for |
build_settings | object | The site’s own build configuration — see Update Site Build Settings |
rename_status | string? | Present only during or after a failed URL-moving rename: renaming or failed. Absent when idle |
rename_error | string? | Present only when rename_status is failed |
created_at / updated_at | string | Timestamps |
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:
- the site with
is_default: true - otherwise the site whose slug is
main - otherwise the only site, if the project has exactly one
- several sites, none marked →
409this project has multiple sites — specify one or set a default site - 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/sitesResponse: 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/sitesBody:
{
"name": "admin",
"environment": "production",
"app_tier_slug": "b",
"replicas": 2
}| Field | Type | Description |
|---|---|---|
name | string | Required. The slug is derived from it |
environment | string? | development (default), staging or production |
app_tier_slug | string? | Compute tier to create the site on. Omitted → the plan’s default_app_tier |
replicas | int? | 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:
| Status | Code | Meaning |
|---|---|---|
400 | unknown_app_tier | app_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 |
409 | duplicate_site_slug | Another site in this project already derives that slug |
422 | site_name_too_long | The 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/:siteIdResponse: 200 — the site object. This is also the endpoint you poll while a
rename is running.
Update Site
PUT /api/v1/projects/:id/sites/:siteIdBody:
{
"name": "public-api",
"acknowledge_url_change": true
}| Field | Type | Description |
|---|---|---|
name | string | Required. New display name |
acknowledge_url_change | boolean? | 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:
| Status | Code | Meaning |
|---|---|---|
400 | acknowledge_url_change_required | Slug-changing rename without the acknowledgement |
409 | duplicate_site_slug | Another site in this project already derives that slug |
409 | target_url_taken | The URL that name would produce belongs to another project, or is live on a sibling site |
409 | target_namespace_taken | The Kubernetes namespace that name would produce belongs to another project |
409 | rename_in_progress | A rename or a deployment is already running on this site, or the project itself is being renamed |
422 | site_name_too_long | The 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/tierBody:
{
"app_tier_slug": "b",
"replicas": 2
}| Field | Type | Description |
|---|---|---|
app_tier_slug | string | Required. The new compute tier |
replicas | int | Required. 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:
| Field | Type | Description |
|---|---|---|
applied | boolean | true when the running app is moving to the new size now |
apply_note | string? | 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:
| Status | Code | Meaning |
|---|---|---|
400 | — | replicas below 1 |
409 | tier_too_small | The 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 |
503 | metrics_unavailable | The 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/:siteIdBody: 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.appURL) - 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:
| Status | Code | Meaning |
|---|---|---|
400 | confirm_required | Main or default site, and confirm was missing or false |
409 | deploy_in_flight | A queued, building or deploying deployment is running on this site |
409 | rename_in_progress | The project is being renamed |
409 | site_rename_in_progress | This site is being renamed |
Set Default Site
POST /api/v1/projects/:id/sites/:siteId/set-defaultMarks 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-settingsHow 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": ""
}| Field | Description |
|---|---|
dockerfile_path | Path 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:
| Status | Code | Meaning |
|---|---|---|
400 | — | builder is not "" or "auto", or port is out of range |
409 | site_rename_in_progress | This site is being renamed |
Get Site Env Vars
GET /api/v1/projects/:id/sites/:siteId/envResponse: 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/envBody:
{
"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/effectiveThe 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:
| Status | Meaning |
|---|---|
403 | insufficient role — the caller is below admin on the project |
404 | The project is not visible to the caller, or the site is not in that project |
503 | The 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-sessionsOpens 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:
| Status | Meaning |
|---|---|
403 | insufficient role — the caller is below admin on the project |
404 | The project is not visible to the caller, or the site is not in that project |
409 | The 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/closeRevokes 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.