# iblai-api-hosting

> Mirrored from [`iblai/vibe`](https://github.com/iblai/vibe) · [`skills/ship/iblai-api-hosting/SKILL.md`](https://github.com/iblai/vibe/blob/main/skills/ship/iblai-api-hosting/SKILL.md). This page is generated — edit it in the repository, not here.

> **With a runbook:** `/iblai-vibe-ops-deploy` walks a human through building and
> shipping an app step by step — this skill is the headless twin (same endpoints,
> no narrative). That runbook writes its URLs with the older `ai-mentor` prefix;
> both prefixes resolve to the same views.

Deploy a built web app to ibl.ai hosting and manage it entirely over HTTP. One
family under `providers/vercel/hosting/` covers the whole lifecycle:

- **Deploy** — POST a zip of the build, get `202 Accepted`, poll until live.
- **Read** — list an owner's projects, or poll one project for build state, the
  live URL, attached domains and (on failure) the tail of the build log.
- **Domains** — attach a custom domain, read the DNS records it needs, poll until
  verified, detach it.
- **Boot lookup** — a public, unauthenticated endpoint a hosted app calls once at
  start-up to learn which organization it fronts.

## The schema is the contract

These endpoints live on the **Data Manager** service and its live OpenAPI schema
is the single source of truth — the URLs and fields below exist for orientation
and **can drift between releases**. Validate before building requests:

- **Schema (raw):** `https://api.iblai.app/dm/api/docs/schema/`
- **Swagger UI:** `https://api.iblai.app/dm/api/docs/`

```bash
curl -sS "https://api.iblai.app/dm/api/docs/schema/" -o /tmp/iblai_schema.yaml
grep -nE "vercel/hosting" /tmp/iblai_schema.yaml
```

Treat any mismatch between this skill and the schema as a bug in the skill — the
deployed schema wins.

## Auth & conventions

- **`dm_url`** = `https://api.iblai.app/dm` — these are **Data Manager** endpoints
  reached through the gateway's `/dm` prefix.
- **`base`** = `{dm_url}/api/ai-agent/orgs/{org}/users/{username}/providers/vercel/hosting`
  for every endpoint except the public boot lookup.
- **Header:** `Authorization: Api-Token $IBLAI_API_KEY` on every request except the
  public boot lookup, which takes none.
- **Placeholders:**
  - `{org}` = your organization key = `$IBLAI_ORG`. On the wire it is the `orgs/{org}`
    path segment and the `platform_key` response field — same value.
  - `{username}` = the **owner** of the project, not necessarily the caller. An
    organization admin acts on a member's path.
  - `{pk}` = the integer project id, i.e. the `id` field from a list or deploy response.
- **`provider`** — every endpoint below accepts an optional `provider` query param.
  It defaults to `vercel` and `vercel` is the only accepted value; anything else is
  a `400`. You can safely omit it.
- **Prefix twin:** `{dm_url}/api/ai-mentor/...` resolves to exactly the same views.
  The `ai-agent` spelling used here is canonical; `ai-mentor` is marked deprecated in
  the generated schema but still works.
- **Admin only.** See *Access* below — this is not a member-reachable family, and
  granting the RBAC verbs does not make it one.
- Not connected yet? Run **`/iblai-api-login`** first.

## Access

Every endpoint under `base` requires the caller to be an **organization admin** of
`{org}`, an organization-scoped Platform API Token for `{org}`, or a server-to-server
service account. A member is refused even when a custom role grants the verbs — the
admin rule is checked independently of the grant, because hosting can draw on an
ibl-supplied hosting account and the deploy is billed to the organization.

The RBAC verbs, for building a role, are under `Ibl.Mentor/HostingProjects/` at
resource `/platforms/{id}/hosting-projects/`:

| Operation | Verb |
|---|---|
| `GET deployment/` | `Ibl.Mentor/HostingProjects/list` |
| `POST deployment/` | `Ibl.Mentor/HostingProjects/action` |
| `GET deployment/{pk}/` | `Ibl.Mentor/HostingProjects/read` |
| `DELETE deployment/{pk}/` | `Ibl.Mentor/HostingProjects/delete` |
| `GET dns/` | `Ibl.Mentor/HostingProjects/read` |
| `POST dns/` | `Ibl.Mentor/HostingProjects/write` |
| `DELETE dns/` | `Ibl.Mentor/HostingProjects/write` |

Note `DELETE dns/` enforces `write`, not `delete`, despite what the `delete` verb's
registered description suggests. The table is what the server does.

## Reads

### Projects

- **GET** `{base}/deployment/` — list every hosting project owned by `{username}`,
  newest first. No pagination or filtering. Returns
  `{"projects": [...], "stale": false, "upstream_error": null}`.
  Each project carries `id`, `name`, `url`, `vercel_alias`, `last_ready_state`,
  `push_state`, `deployment_hash`, `created_at`, and a `latest_deployment` object
  (`uid`, `state`, `created`, `url`) — or `null` for any project the hosting
  provider did not return.
  **This endpoint answers `200` even when the provider is unreachable**: you get the
  last-known snapshot with `stale: true` and `upstream_error` filled. Treat that as
  "this data is old", never as a failed request. An owner with no projects gets an
  immediate empty list that is never stale.

- **GET** `{base}/deployment/{pk}/` — poll one project. Returns every list field
  **except** `latest_deployment`, plus `stale`, `upstream_error`, `extras_error`,
  and a `deployment` object. Once the push has landed and the build has finished it
  also carries `project` (framework, node version, production URL), `domains[]`,
  `recent_deployments[]` (up to 5) and — only when the build failed —
  `build_log_tail` (the last 20 log lines).
  `deployment` is `null` until a deploy has been recorded, `{"id", "ready_state":
  "NOT_FOUND", "aliases": []}` if the project was removed outside ibl.ai, and
  otherwise `{"id", "url", "ready_state", "target", "inspector_url", "created_at",
  "building_at", "ready_at", "alias_assigned", "aliases": [...], "error_code",
  "error_message"}`. `aliases` is sorted shortest-first, so `aliases[0]` is the
  stable production host.
  → Full field tables and every response shape: [`references/fields.md`](https://github.com/iblai/vibe/blob/main/skills/ship/iblai-api-hosting/references/fields.md).

### Domains

- **GET** `{base}/dns/?project={pk}` — list the custom domains attached to a project.
  Returns `{"domains": [{"name", "apex_name", "verified", "created_at"}]}`.
- **GET** `{base}/dns/?project={pk}&domain={fqdn}` — inspect one domain. Returns
  `name`, `apex_name`, `verified`, `misconfigured`, `configured_by`, the provider's
  raw `verification[]` array, and `required_records[]` — the exact DNS records to
  create at the registrar, each `{type, name, value, reason}`.
  **This read has a side effect:** an unverified domain is re-checked against the
  live DNS on every call, so it is the poll target after you create the records, not
  a cheap lookup. `domain` must be lowercase, ≤253 chars, with an alphabetic TLD;
  Unicode domains are not accepted.

### Boot lookup (public)

- **GET** `{dm_url}/api/ai-agent/providers/vercel/hosting/projects/{vercel_project_id}/`
  — map a hosting project id to the organization it serves. Returns exactly
  `{"platform_key": "acme"}`, or `404` for an unknown or malformed id.
  **No authentication, no token, no rate limit.** A hosted app calls this once per
  server instance at boot, with its own provider-injected project id, because it has
  no credential yet and a read-only filesystem. It returns only the organization key.
  Answers are cached for about 60 seconds; a miss is never cached, so an app that
  asks a moment too early gets the right answer on its next try. Being unthrottled is
  deliberate — serverless egress IPs are shared across customers, so a per-IP bucket
  would lock out every hosted app at once. Do not poll it.

## Writes

### Deploy

- **POST** `{base}/deployment/` — upload a build and start a deploy.
  **`multipart/form-data` only** — a JSON body is rejected with `415`.

  | Field | Type | Required | Notes |
  |---|---|---|---|
  | `file` | binary | **yes** | The build as a zip, ≤50 MB compressed |
  | `project` | string | **yes** | `^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$`, ≤64 chars |
  | `framework` | enum | no | `static` (default) or `nextjs` |
  | `deployment_hash` | string | no | 64 lowercase hex chars, or blank |
  | `subdomain` | string | first deploy | `^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$` — the label the project is served at under the instance's shared domain, like a username. Required when the project has no address yet and a shared domain is on offer (`400` with a `subdomain` key otherwise); never with `domain` |
  | `domain` | string | no | A custom domain the organization has already configured; never with `subdomain` |

  Returns **`202 Accepted`** with the project object (`push_state: "pending"`,
  `url: null`, `site_url` already final — the address chosen, never derived
  from the name) — nothing has reached the hosting provider yet. Poll
  `GET {base}/deployment/{pk}/` from here.

  The archive is checked synchronously. `.git/`, `node_modules/`, `__MACOSX/` and
  `.next/` trees are stripped, as are `.DS_Store` and `iblai.env` files, before any
  limit is applied; what survives must be ≥1 file, ≤2000 files and ≤200 MB
  uncompressed, with no absolute, `..`-relative or Windows-drive member paths.
  **`.next/` being stripped is load-bearing for `framework=nextjs`**: ship source and
  let the provider build it, because a bundled build output is discarded.
  Corruption *inside* a member is not caught here — it surfaces later as
  `push_state: "failed"`.

  Re-posting the same `project` slug redeploys in place: same `id`, same row.
  `deployment_hash` is recorded on success **including when blank**, so a push
  without one clears a previously recorded hash — "compare hashes to decide whether
  to redeploy" is only safe if every client sends one.

- **DELETE** `{base}/deployment/{pk}/` — **Confirm with the user first.** Deletes the
  hosting project and every custom domain attached to it, and retires the local
  record. `204`, empty body. Deleting a project already removed upstream succeeds.
  The `id` is not reusable; the `project` slug is.

### Domains

- **POST** `{base}/dns/` — attach a custom domain, or choose the project's subdomain
  under the instance's shared domain. Body `{"project": {pk}, "domain":
  "app.example.com"}` **or** `{"project": {pk}, "subdomain": "smallsite"}` — exactly
  one; neither is a `400`. Returns `201` with the same shape as the domain detail read,
  including `required_records[]`; a subdomain the project already has answers `200`,
  a taken one `409`.
  The domain is reserved across **all** ibl.ai app domains, not just hosting ones, so
  a clash with another app's domain fails here without touching the provider. A
  provider-side failure rolls the reservation back, so a failed attach leaves nothing
  behind and is safe to retry.
  Attaching does not make the domain live — create `required_records` at the
  registrar, then poll the domain detail read until `verified: true` and
  `misconfigured: false`. Propagation is the registrar's clock.

- **DELETE** `{base}/dns/?project={pk}&domain={fqdn}` — **Confirm with the user
  first.** Detach a custom domain. `204`, empty body. Both query params are required.
  Detaching a domain the provider no longer knows about succeeds.

## Example

Deploy a build, poll until it is live, then read the URL:

```bash
dm_url="https://api.iblai.app/dm"
base="$dm_url/api/ai-agent/orgs/$IBLAI_ORG/users/$IBLAI_USERNAME/providers/vercel/hosting"

# 1. deploy — 202, returns the project id
pk=$(curl -s -X POST "$base/deployment/" \
  -H "Authorization: Api-Token $IBLAI_API_KEY" \
  -F "file=@dist.zip" -F "project=my-app" -F "framework=static" \
  | jq -r '.id')

# 2. poll until terminal
while :; do
  body=$(curl -s "$base/deployment/$pk/" -H "Authorization: Api-Token $IBLAI_API_KEY")
  push=$(jq -r '.push_state' <<<"$body")
  state=$(jq -r '.deployment.ready_state // ""' <<<"$body")
  echo "push=$push ready=$state"
  [ "$push" = "failed" ] && { jq -r '.push_error' <<<"$body"; break; }
  case "$state" in READY|ERROR|CANCELED) break ;; esac
  sleep 3
done

# 3. the live URL (null until READY)
jq -r '.url' <<<"$body"

# 4. attach a custom domain and read the records to create
curl -s -X POST "$base/dns/" -H "Authorization: Api-Token $IBLAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"project\": $pk, \"domain\": \"app.example.com\"}" \
  | jq '.required_records'
```

## Notes

- **`url` is `null` until the build is `READY`** — and it blanks again while a
  redeploy is in flight. This is deliberate: the hosts named for an unfinished build
  are revised when it goes live, so an early URL would 404 and then move.
  `last_ready_state` always travels in the same response, so the body never
  contradicts itself.
- **Terminal states are `READY`, `ERROR`, `CANCELED`.** Stop polling at any of them.
- **Poll cost is not flat.** While a deploy is in flight the poll is one upstream
  call; once the push has landed and the build is terminal it becomes three to five.
  Poll every 2–5 s while in flight, then stop. Hammering a finished project is what
  trips the provider's rate limit.
- **Four strings, two of them addressable.** `name` is your slug;
  `vercel_project_name` is a derived identifier that is **not a hostname** and may be
  truncated; `vercel_alias` is the confirmed host without a scheme; `url` is
  `https://` + that alias while the latest build is READY. Never rebuild a URL from
  `vercel_project_name` — read `url`, `vercel_alias`, or `deployment.aliases[0]`.
- **The deployed host is also the Stripe redirect allowlist.** A Stripe address the
  platform sends a browser back to — the app paywall's Connect with Stripe
  `return_url`, a hosted checkout's `success_url`/`cancel_url` — is accepted only for
  a confirmed hosting alias or an attached custom domain of the same organization
  (plus localhost). A URL reconstructed from the project name gets rejected. See
  `/iblai-vibe-monetization-app-paywall`.
- **`409` on deploy means a push is already in flight** for that project. Poll until
  terminal and retry; a stuck push clears itself after about 15 minutes, after which
  it reads as `failed` and a new deploy is accepted.
- **`extras_error` is not `stale`.** `extras_error` set with `stale: false` means the
  build status is live and only the extra sections (domains, recent deployments)
  failed to load. `stale: true` means the whole body is a stored snapshot.
- **Two different `429`s.** `{"detail": ...}` is ibl.ai throttling you — slow down.
  `{"error": ..., "code": ...}` is the hosting provider's own limit relayed through;
  honour `Retry-After` when present, and treat its absence as "still rate limited",
  not "outage".
- **Timestamps are mixed.** `created_at`/`updated_at` on the project are ISO-8601;
  provider-sourced values inside `deployment`, `project`, `latest_deployment`,
  `recent_deployments` and the domain list are **epoch milliseconds**.
- **`push_error` is provider text, not an enum.** Display it; never branch on it.
- **There are no server-side retries** anywhere in this family. Every retry is yours.
- **`404` from a project endpoint does not mean "no such project"** for an
  unauthorized caller — authorization is checked before the lookup, so a non-admin
  always gets `403` and can never enumerate ids.
- For the list of an organization's custom domains across every app type, see
  `/iblai-api-org`.

## Reference material

The endpoints above are the primary; these carry the exhaustive lookup material.

- [`references/fields.md`](https://github.com/iblai/vibe/blob/main/skills/ship/iblai-api-hosting/references/fields.md) — every field of the project, deployment, extras and domain objects, with types, and the four shapes `deployment` can take.
- [`references/errors.md`](https://github.com/iblai/vibe/blob/main/skills/ship/iblai-api-hosting/references/errors.md) — the full error surface: every status, the condition that produces it, what the caller should do, and how upstream provider failures are translated.
