// the api

API reference

Deploy a real root Linux server with one HTTP call. Plain REST, curl-able, no MCP install. Base URL https://thevibehosting.com · JSON in/out. Agents: read SKILL.md first.

Auth

The user layer is an account; the credential is an api_key. The first POST /create (no auth) creates your account and returns the key. Send it on every other call:

Authorization: Bearer vibe_xxxxxxxx
Anonymous ≠ no identity — it's an account with no verified phone; the api_key is still your credential. Phone verification just attaches a phone to the same account; the key doesn't change. Auth-required endpoints return 401 without a token; only /create and /feedback work anonymously.

Deploy your app

It's a full root Linux box — SSH in and run whatever you want (web app, worker, bot, cron, a database, compile things). Nothing forces a particular layout. The options below are just the convenient ways to put a web app on your public HTTPS subdomain; if you don't serve HTTP, ignore them.

Your subdomain routes to port 80 in the container. To publish there:

1 — Static: drop files in /root/www (served immediately, nothing to kill).   2 — Your own server: point our supervisor at it with an executable /root/run.sh bound to 0.0.0.0:80 — it gets auto-restarted and survives restarts. Then free :80 from the default placeholder:

# optional — only if you want YOUR long-running server on the public :80
cat > /root/run.sh <<'SH'
#!/usr/bin/env bash
cd /root/myapp
exec python3 server.py        # must listen on 0.0.0.0:80
SH
chmod +x /root/run.sh
pkill -f busybox              # drop the default placeholder; container stays up
Keep it light (target RAM < 128 MB). Per-server RAM ceiling: 128 MB (no phone) / 512 MB (verified). Disk quota: 256 MB (anon) / 1 GB (verified), always-on scales with the plan's RAM — exceeding it suspends the server. Don't add keep-alive pings — idle servers sleep = free. Need a non-HTTP TCP port? Use /expose.

Errors & conventions

CodeMeaning
400bad input
401missing/invalid api_key
402always-on plan needs credit
403limit (anon IP server cap, always_on, domain)
404server not found / not yours
409subdomain taken
429rate limit
503at capacity / not configured

When the free grant is exhausted and there's no credit, read endpoints return an upgrade envelope:

{ "upgrade_required": true, "reason": "free_tier_exceeded",
  "upgrade_url": "https://thevibehosting.com/account/recharge",
  "message": "Server will be paused in 48h. Top up credit, or verify a phone…" }

Objects

// Credits
{ "tier":"anonymous", "ram_gb_hours_left":100.0, "egress_gb_left":5.0,
  "disk_mb_left":512.0, "balance_usd":0.0 }

// Server
{ "id":"srv_…", "subdomain":"abc123.thevibehosting.com", "state":"running",
  "always_on":false, "plan":null, "suspended":false, "flag":null,
  "exposed_ports":[{"public":"46.225.188.115:40860","container_port":5432,"protocol":"tcp"}],
  "usage":{"disk_mb_used":0.0,"domains":[]}, "credits":{…} }

Endpoints

POST /create no auth → creates account

Create a server. Optional body: { region?, image?, subdomains?, subdomain?, always_on?, plan? }. plan (standard|pro|max = 1|2|4 GB; $3/$5/$9 mo) makes it always-on and needs credit. image is restricted to vetted base images (currently the default Ubuntu base); an unsupported value returns 400.

Pick a nice URL. Pass subdomains — a preference-ordered list — and the first available one is taken (include fallbacks). Slugs are lowercased; letters, digits, hyphens. If all are taken a random slug is assigned and subdomain_note says so (no error). A lone subdomain string still works but a clash returns 409.

# request — choose a name, first free wins
curl -sX POST https://thevibehosting.com/create -H 'content-type: application/json' \
  -d '{"subdomains":["caffeine-tracker","caffeine-app","caffeine"]}'
# 200
{ "id":"srv_ab12cd34ef",
  "ssh":"ssh root@46.225.188.115 -p 28210",
  "ssh_private_key":"-----BEGIN OPENSSH PRIVATE KEY-----\n…",
  "subdomain":"caffeine-tracker.thevibehosting.com",
  "subdomain_note":null,
  "api_key":"vibe_0123…", "credits":{…}, "ONBOARDING":"…" }
Check subdomain to see which choice you got. subdomain_note is non-null only when all your names were taken and a random one was assigned (rename later via …/subdomain). Save ssh_private_key (chmod 600) and api_key. SSH is ready within ~1–3 s. SSH is IP:port (the subdomain is HTTP only).
Server limits: verified accounts get unlimited servers (all sharing one credit balance + free grant). Anonymous accounts are capped at 2 servers per creator IP (128 MB RAM / 256 MB disk each) — verify a phone to lift it.

GET /account

Who this api_key is.

{ "account_id":"acc_…","tier":"anonymous","phone_verified":false,"projects":1,"credits":{…} }

GET /servers

Array of your Server objects. curl -s …/servers -H "Authorization: Bearer vibe_…"

GET /servers/{id}

One server's status / usage / credits. 404 if not yours.

GET /servers/{id}/usage

{ "server":"srv_…", "credits":{…}, "disk_mb_used":0.0 }

Returns the upgrade envelope instead if the grant is exhausted.

POST /servers/{id}/subdomain

{ "subdomain":"newslug" }{ "subdomain":"newslug.thevibehosting.com", "restarted":true, "warning":"…" }. 409 if taken. ⚠️ Recreates the server (re-points routing): only /root persists — data written outside /root (e.g. /var/lib/mysql) is wiped, so keep DBs/uploads under /root. /root/run.sh + SSH keys kept; open SSH drops — reconnect (host key unchanged).

POST /servers/{id}/domain verified tier

{ "domain":"example.com" } → CNAME instructions + verification status.

POST /servers/{id}/expose

Publish a raw TCP/UDP port (for non-HTTP services; HTTP on :80 is already your subdomain); max 5 ports/server. ⚠️ Recreates the server — same as rename: only /root persists (data outside /root, e.g. /var/lib/mysql, is wiped) and open SSH drops. Expose ports before loading data, or keep data under /root.

# request  { "container_port":5432, "protocol":"tcp" }
# 200      { "public":"46.225.188.115:40860", "container_port":5432, "protocol":"tcp" }

POST /servers/{id}/ssh-key

{ "public_key":"ssh-ed25519 AAAA…" }{ "ok":true }

DELETE /servers/{id}

Delete the server and its data. → { "ok":true }

POST /account/verify-phone

Attach a verified phone → upgrades to the verified tier (unlimited servers on one shared balance, custom domains, always-on, bigger grant). Agent-driven, two steps.

# step 1 — show the returned disclosure first, then send the code (needs consent)
{ "phone":"+420600000000", "consent":true }   → { "sent":true, … }
# step 2 — confirm the SMS code
{ "phone":"+420600000000", "code":"123456" }   → { "verified":true, "tier":"verified", … }
The number is never resold or shared, used only for hosting notifications, with an opt-out link in every SMS.

POST /account/recover

Lost your api_key? Recover it by re-verifying the phone on the account. No auth needed; two steps.

# step 1 — send a code to the phone on the account
{ "phone":"+420600000000" }              → { "sent":true, … }
# step 2 — confirm; returns your api_key
{ "phone":"+420600000000", "code":"123456" } → { "recovered":true, "api_key":"vibe_…", … }
Only works if a phone was verified on the account — another reason to verify one.

POST /account/recharge verified phone alias: /account/topup

Add credit in advance, any time, via Stripe Checkout. { "amount_usd":5 }{ "checkout_url":"https://checkout.stripe.com/…" }. Open the URL to pay; balance is credited automatically (min $5).

Requires a verified phone. Until you verify one, recharge returns 403 — verify your phone first with POST /account/verify-phone. This keeps anonymous accounts from adding credit.

POST /feedback no auth ok

Tell us what to add or fix — we read every one. Add email for a reply.

{ "kind":"feature", "message":"add a /logs endpoint", "email":"you@x.com" }
→ { "ticket_id":"fb_…", "thanks":"Recorded, thank you!" }
# kind: feature | bug | friction | limit | image_request | other