# koigrid > Flat-rate managed cloud on EU servers: deploy apps, host files and object storage, > fully manageable programmatically by AI agents and developers. The anti-AWS: one > predictable price with generous bandwidth included, no surprise egress bills, no lock-in. ## Deploy freely — koigrid NEVER blocks you upfront (read this first) - You can deploy ANY app or create ANY resource on the FREE tier right now. koigrid does NOT block creation when you go over the free plan's counts (e.g. a 2nd app, more databases): the create SUCCEEDS. There is no "upgrade to Pro before you can deploy" gate. Just deploy. - App COMPUTE SIZE is NOT capped by your plan either: request the RAM/CPU your app needs (e.g. a 2-4GB Next.js app on the free tier) via memoryMb/cpus on create, or PUT /apps/:id/resources {"memoryMb":4096} — you get it. The plan number is just what's "included" for the upsell nudge, not a cap. (Default is 512MB if you don't ask for more; the free DB is single-node vs paid auto-failover, but that's a feature, not a block.) - ⭐ REPLICAS AND AUTOSCALING follow the SAME rule (since 2026-07-25 — they used to be the exception and returned plan_limit): PUT /apps/:id/scale {"replicas":6} and PUT /apps/:id/autoscale {"enabled":true,...} WORK on any plan, including free. The response carries overPlan:true when you exceed what your plan includes — that is an upsell nudge, not a rejection. This matters because measuring your PEAK is what decides a real migration: you can now run a capacity test at 6-10 replicas with CDN on, on the free tier, BEFORE paying. Only the platform guardrail (12 replicas per app) rejects, and it answers 429 "fair_use_limit" — NOT "plan_limit". That distinction is deliberate: upgrading your plan does NOT raise it, so do not retry after an upgrade. It exists to stop a runaway loop eating the fleet, and it applies identically on every plan. - When you use more than your plan includes you keep working and get an UPSELL nudge (a banner + GET /usage): "your free plan is consumed, upgrade to so your app is not stopped". Pay to expand (Stripe, via the dashboard). If heavy usage is never paid, service may be suspended at koigrid's discretion — so upgrade when nudged. - The ONLY hard limit is a very high anti-abuse fair-use ceiling (e.g. 50 apps, 16GB RAM per app, 12 replicas) that no real project hits; it just stops a runaway agent from exhausting the shared fleet. Hitting it returns 402. - To upgrade: https://koigrid.com/dashboard/billing (add a card, pick a plan — cancelable). Everyone pays eventually; the effort of shipping your app is the only "gate". ## API - Base URL: https://koigrid.com/api/v1 - Auth: HTTP header `Authorization: Bearer koi_YOUR_KEY` - Create a key: https://koigrid.com/dashboard/keys - OpenAPI 3.1 spec: https://koigrid.com/api/v1/openapi.json (machine-readable, all endpoints) - Migration guide: https://koigrid.com/docs/migrate — move Postgres (CDC/logical replication), storage, apps and cron to koigrid - ⭐ DEPLOY A PRIVATE IMAGE (drop-in ECS): creating the app ALSO DEPLOYS IT — no extra call. Build in CI → push to ECR/GHCR → deploy with registry creds. POST /apps {sourceType:'image', image, registry, registryUsername, registryPassword} (or CLI `apps deploy --image --registry --registry-user --registry-password

`). koigrid PULLS + runs the immutable image — no koigrid build, no build-OOM. Creds encrypted, never logged. AWS ECR native (like an ECS execution role): POST /apps {…, registry, registryAwsRegion, registryAwsAccessKeyId, registryAwsSecretKey} → koigrid mints a FRESH ECR token on every deploy (ECR tokens expire in 12 h; never paste a static one). Rotate/clear without recreating: PUT /apps/{id}/registry (`koigrid apps registry … / --clear`). - ⭐ DEPLOYING A REAL APP (Next.js/Nuxt/Astro/Vite/SvelteKit) — READ THIS BEFORE you deploy a framework app: docs/products/DEPLOYING-APPS.md. Front-loads every gotcha that has blocked an agent mid-deploy: build-time env vs runtime env, giving the build the DB URL with a reference var (${{db.x.DATABASE_URL}}), the `ENV X=${ARG}` clobber, big-SSG type-check OOM (→ typescript.ignoreBuildErrors), image-size limit, and Supabase pgvector/DB-sizing for restores. Each failure's fix is in the error's own words — read `GET /apps/:id/logs?type=build` when a build fails; you should NEVER need to reproduce a failure locally. - CLI — RECOMMENDED FOR AGENTS: `npm i -g koigrid` (or `npx koigrid`). It wraps this API and UNWRAPS the response envelope for you (no {credential}/{connection} parsing) + handles auth → fewer footguns than raw HTTP. `koigrid help --json` introspects ALL commands; every command takes --json. Covers apps, db, redis, storage, jobs, email, events, alarms, usage. Examples: `koigrid db create`, `koigrid storage buckets create`, `koigrid apps deploy`. Use raw HTTP below only if you need it. - Responses are JSON. Errors: {"error","detail"}; 401 no/invalid token; 403 missing scope; 402 {"error":"budget_exceeded"} when the token spent its max_cost_cents; 500 includes an "errorRef". - ENVELOPE (important): success responses WRAP the resource under its type key — they are NOT flat. Single resource → {"bucket":{…}}, {"database":{…}}, {"credential":{…}}, {"connection":{…}}, {"app":{…}}, {"job":{…}}, {"deployment":{…}}, {"volume":{…}}. Collections → {"buckets":[…]}, {"databases":[…]}, {"keys":[…]}, {"apps":[…]}, … So read `resp.credential.accessKey`, `resp.connection.uri`, `resp.bucket.id` — never `resp.accessKey` (that is undefined). - Idempotency (safe retries): send header `Idempotency-Key: ` on any mutation (POST/PUT/PATCH/DELETE). A retry with the SAME key returns the SAME response instead of creating a duplicate (scoped to your token + method + path; kept 24h). If the original is still running you get 409 idempotency_in_progress. Replays carry header Idempotent-Replayed: true. Errors are NOT cached (you can genuinely retry). Recommended for agents. - Pagination (opt-in): list endpoints accept ?limit=N (1..200, default 50) and ?cursor=. WITHOUT these params you get the full list (unchanged). WITH them you get { : [...page], nextCursor, total } — pass nextCursor back as ?cursor to get the next page (null = last page). Paginated: apps, databases, redis, jobs, checks, queues, workflows, buckets, webhooks, flags, status-pages, log-drains, sandboxes. - ROLE, not just scope: changing ORG-LEVEL CONFIG additionally requires the token owner to be an org owner/admin — spend limit (PUT/DELETE /spend), SMTP config (/email/config) and OTEL exporter (/otel). A member-owned token gets 403 forbidden even WITH the right scope. Using those products (POST /email/send) needs only the scope. - Token scopes (a key can be limited to a subset). Convention: :read (GET) + :write (mutate). Full list: apps:read, apps:write, apps:deploy · databases:read, databases:write · redis:read, redis:write · jobs:read, jobs:write · storage:read, storage:write · queues:read, queues:write · workflows:read, workflows:write · observability:read, observability:write (events/metrics/alarms/anomalies) · checks:read, checks:write · heartbeats:read, heartbeats:write · webhooks:read, webhooks:write · logs:read, logs:write (Log Drains — note the scope is "logs", not "log-drains") · spend:read, spend:write · bots:read, bots:write · otel:read, otel:write · dns:read, dns:write · flags:read, flags:write · status:read, status:write · config:read, config:write · analytics:read, analytics:write · errors:read, errors:write · speed:read · sandboxes:read, sandboxes:write · images:read, images:write · waf:read, waf:write · registry:read, registry:write · email:send, email:write · dns:read, dns:write · members:read, members:write · projects:read, projects:write · tokens:read, tokens:write · notifications:read, notifications:write · connect:read, connect:write · rewards:read, rewards:write · wordpress:read, wordpress:write (Managed WordPress hosting) · manifest:write (multi-service cutover) · audit:read · share:write · links:read, links:write · usage:read. A key with NO scopes = full access. A key can never mint a token with more scopes than itself. ### Endpoints - GET /me — current user - GET /tokens — list your API tokens (masked; never the secret) - POST /tokens {"name","scopes"?,"expiresInSeconds"?,"maxCostCents"?,"projectId"?} — mint a scoped token (AI-native: give an agent a short-lived, scope-limited token). Returns the token ONCE. Anti-escalation: cannot exceed the calling token's scopes. Scope tokens:write - DELETE /tokens/:id — revoke a token. Scope tokens:write - GET /connect/grants — list Connect grants (agent credential broker). Scope connect:read - POST /connect/grants {"name","allowedScopes":[…],"maxTtlSeconds"?,"maxCostCents"?,"projectId"?} — create a broker grant. Returns the grant secret (koicg_…) ONCE. Scope connect:write - PATCH /connect/grants/:id {"enabled"?,"name"?,"allowedScopes"?,"maxTtlSeconds"?} — pause/reconfigure a grant. Scope connect:write - DELETE /connect/grants/:id — revoke a grant (kills all future mints). Scope connect:write - POST /connect/token {"scopes":[…],"ttlSeconds"?,"purpose"?} — EXCHANGE a grant for an ephemeral scoped token. Auth with the GRANT SECRET: Authorization: Bearer koicg_… (NOT a normal token). Returns {token, expiresAt, scopes}. Requested scopes must be ⊆ the grant; TTL clamped to the grant ceiling. - GET /notifications/preferences — which alert categories you get by email (alarms/anomalies/spend) - PUT /notifications/preferences {"category":"spend","emailEnabled":false} — mute/unmute a category. Scope notifications:write - GET /projects — list projects (a project groups resources within your org, like Vercel/GCP) - POST /projects {"name":"prod"} — create a project - DELETE /projects/:id — delete a project (not the default; must be empty) - PATCH /projects/:id {"privateNetwork":true} — toggle private networking: resources in the project reach each other over an encrypted WireGuard mesh as `http://.internal` (cross-runner) - NOTE: create app/db/redis/bucket/job with {"projectId":"…"} to place it in a project (default: your Default project) - GET /buckets — list storage buckets - POST /buckets {"name":"backups"} — create an S3 bucket - DELETE /buckets/:id — delete a bucket (soft-delete → trash; data preserved) - GET /buckets/:id/objects?prefix= — browse objects by prefix (folders + files with size/date). Object explorer - POST /buckets/:id/objects/upload-url {"key","contentType"?} — presigned URL to upload an object (PUT direct to S3) - GET /buckets/:id/objects/download-url?key= — presigned URL to download an object - DELETE /buckets/:id/objects?key= — delete an object (recoverable via versioning) - GET /buckets/:id/versions?prefix= — object versions + delete-markers (file trash; buckets are versioned) - POST /buckets/:id/versions {"key":"path/file.txt"} — restore (undelete) an object. Deleting/overwriting a file never destroys it: versioning is ON, old versions kept 30d → recoverable. Scopes: storage:read|write. - GET /storage/keys — list S3 access keys - POST /storage/keys — create a scoped S3 access key (secret shown once) - DELETE /storage/keys/:id — revoke an S3 access key - GET /apps — list apps - POST /apps {"name","image","port"}— create an app (container image or git repo). Git build settings (override nixpacks autodetect): {"buildCommand","installCommand", "startCommand","rootDirectory","nodeVersion"}. A koigrid.json in the repo overrides these ({"build","install","start","rootDirectory","nodeVersion"}). - GET /apps/:id — app detail (status, url) - PATCH /apps/:id {"name"} — rename the app (metadata; slug/URL unchanged) - DELETE /apps/:id — delete an app - POST /apps/:id/deployments — deploy the app (returns status + url). Body {"sourceType":"archive", "archiveKey":"…"} deploys uploaded local code (see source-upload). - POST /apps/:id/source-upload — get a presigned URL to upload LOCAL code (tar.gz) → deploy without git or Docker. Easiest via CLI: `koigrid apps deploy --dir ./`. - PRIVATE git repo: pass a token — `koigrid apps deploy --repo --repo-token ` (or POST /apps {"sourceType":"git","repo","repoToken"}). Token stored encrypted, redacted from logs. - HEALTH CHECK: POST /apps {"healthCheckPath":"/api/health"} (CLI --health-path) → koigrid probes that path after each deploy. A 5xx / no-response = the container runs but the app is BROKEN (e.g. can't reach its DB) → koigrid emits app.unhealthy instead of calling it healthy. - GET /apps/:id/deployments — deployment history (each has build logs + image). A FAILED deployment carries a structured `error` code — BUILD: build_oom, build_export_failed, runner_unreachable, clone_auth_failed, build_daemon_unavailable; RUNTIME (container booted but failed readiness = YOUR config): missing_env (a required env var isn't set), db_connect_failed (bad DATABASE_URL / DB down / self-signed TLS → USE THE CA koigrid GIVES YOU: GET /databases/:id/connection returns caCert (PEM) and sslVerifiedUri. Node: new Pool({connectionString, ssl:{ca: caCert}}). psql/libpq: use sslVerifiedUri (sslmode=verify-ca) + sslrootcert. Do NOT set NODE_TLS_REJECT_UNAUTHORIZED=0 — it disables TLS verification for the WHOLE process, so Stripe/S3/every outbound call stops being verified too), redis_connect_failed, oom_runtime (raise memoryMb), port_mismatch, crashloop, unhealthy — and `logs` that BEGIN with the human cause + fix. E.g. build_oom = your build ran out of memory (a big SSG `next build` needs 4-8GB, the runner is smaller, the kernel OOM-kills it silently) → reduce generateStaticParams, pre-build locally & deploy the artifact (`apps deploy --dir`), or use a larger plan. Read `error` first; don't reproduce locally. - POST /apps/:id/rollback {"deploymentId":"…"} — roll back to a previous deployment (no rebuild) - POST /apps/:id/pause — stop the app WITHOUT deleting it: containers are torn down (the runner frees RAM/CPU) while config, env vars, domains, volumes and deployment history stay. Idempotent. Paused apps are never woken up by autoscaling, runner failover or a GitHub push — only by /resume. - POST /apps/:id/resume — relaunch a paused app from its last live image (no rebuild) - GET /apps/:id/previews — list preview deployments (per branch) - POST /apps/:id/previews {"branch":"feature/x","dbBranchId"?,"dbEnvKey"?} — create a preview from a branch (git apps only) → deploys to its own URL -.apps.koigrid.com. Delete a preview: DELETE /apps/:previewId. BRANCH-PER-PR: pass dbBranchId (a running database BRANCH, see /databases/:id/branches) → it's wired as DATABASE_URL (or dbEnvKey) into the preview AND deleted together with the preview. Each PR gets its own app + isolated database. - GET /apps/:id/rules — redirect/rewrite/header rules (vercel.json equivalent, per app) - POST /apps/:id/rules {"type":"redirect","source":"/old","destination":"/new","statusCode":301} type ∈ redirect (destination+statusCode) | rewrite (destination) | header (headerName+headerValue). PATCH/DELETE /apps/:id/rules/:ruleId. ⭐ ALWAYS read "enforcement" in the response — it tells you whether the rule is actually running, and a rule that is merely stored changes nothing. Shape: {enforced, servedBy, note, remedy}. servedBy:"central_edge" → enforced:true. The app is served by koigrid's HA edge cluster, which is programmed with your rules. Verified end-to-end over real HTTPS (header appears, redirect 308). servedBy:"runner_edge" → enforced:true (single-runner app on koigrid's own edge). servedBy:"legacy_runner" → enforced:FALSE. The app is served by its runner's legacy Caddy, which does not run rules. Look at "remedy": it is either null or a ready-to-run action. remedy != null (today: apps with sourceType=image) → PUT /apps/:id/scale-out {"enabled":true} + a deploy moves the app to the central edge and your EXISTING rules start applying. Do NOT re-create them. remedy == null (sourceType git/archive) → nothing to do yet: the central edge only serves image-based apps so far. Your rules stay stored and start applying by themselves once that runner moves to koigrid's own edge. koigrid deliberately does NOT suggest scale-out here, because it would return 400 for these apps. Do not treat a 201 as "the rule is live": check enforced. koigrid reports this instead of silently accepting. - GET /apps/:id/logs?tail=200 — runtime logs (container stdout/stderr) - GET /apps/:id/env — list env var names (values never returned) - GET /apps/:id/env/verify — ⭐ answers "is the value the container sees the one I think?" WITHOUT revealing it. Per var: present (does it reach the container?), matchesConfigured (same as what koigrid stores? if false the running container predates your change → redeploy) and a short fingerprint you can compare across environments. Add ?key=NAME for a detailed verdict, plus &expected= to also check it is the one YOU expect — send the sha256, never the value (a URL ends up in access logs). Use this instead of redeploying blindly when a secret "looks wrong": it tells you WHICH of the two things is broken — the deploy (stale) or the stored value (configured_value_wrong). Scope: apps:write (it is a guessing oracle, and apps:write already lets you redeploy the app with a command that prints the env). - PUT /apps/:id/scale {"replicas":N} — escalado horizontal (Caddy balancea entre réplicas) - GET /apps/:id/resources — RAM/CPU actuales + máximos del plan - PUT /apps/:id/resources {"memoryMb":2048,"cpus":2} — dimensiona la app (RAM/CPU — pide lo que necesites, SIN tope de plan) + redeploy - PUT /apps/:id/autoscale {"enabled":true,"min":1,"max":5,"targetCpuPct":60} — autoescalado por carga (CPU). el campo 'min' es el **keep-warm / minReplicas**: réplicas que se mantienen calientes SIEMPRE → sin arranque en frío - PUT /apps/:id/scale-out {"enabled":true} — serve the app through koigrid's CENTRAL HA EDGE instead of its runner's Caddy. GET returns {enabled, servedBy, rulesEnforced}. Two things this buys you: · redirect/rewrite/header rules START APPLYING (the runner's legacy Caddy does not run them — see /apps/:id/rules) · replicas are spread ACROSS runners behind the load balancer, not packed on one machine ⭐ MEASURED on the real fleet 2026-07-28 (scripts/canary-scale-out-fleet.ts, green): replicas landed on TWO different runners, traffic served through LB → edge node → replica over the WireGuard mesh, and throughput went from 169 req/s (p50 152ms, p95 325ms) on a single runner to 307-403 req/s (p50 ~57ms, p95 129-178ms) — 2.4x the throughput and 2.7x better latency, with 0 errors. The point is not speed though: with a single runner, that machine dying means ~2 min of failover; spread behind the LB with health checks it is transparent. That is the same redundancy model as ECS multi-AZ behind an ALB. ⭐ AND THE RUNNER-DEATH CASE IS PROVEN, not assumed (scripts/canary-scale-out.ts, 2026-07-28, real infra with WireGuard): with the app on two machines the LB really balances (6/6 of 12 requests); kill one machine and the next 10 requests still return 200 with ZERO errors — the health check ejects the dead replica. That is the ALB parity claim, measured. Preconditions: sourceType=image (git cross-runner is v2), at least 2 meshed runners, and the edge cluster + LB. If any is missing the API tells you which (need_2_meshed_runners / scale_out_v1_image_only / no_lb_vip). Takes effect on the NEXT deployment (POST /apps/:id/deployments). Requires sourceType=image today — a git or archive app returns 400 (cross-runner build is the next iteration), which is why /apps/:id/rules only offers this as a "remedy" for image apps. - PUT /apps/:id/github {"githubRepo":"owner/repo","branch"?,"installationId"?,"autoDeploy"?} — auto-deploy en push - POST /apps/:id/env {"env":{"DATABASE_URL":"…"},"target":"production"} — set env vars (encrypted) + redeploy. target = all (default) | production | preview → only vars matching the deploy's environment are injected. Previews inherit the parent's {all, preview} vars automatically. # BUILD-TIME PUBLIC VARS: frameworks (Next.js/Vite/CRA…) bake PUBLIC vars into the browser bundle at BUILD time, # not runtime. Just set them as normal env vars — any with a PUBLIC prefix are injected into the build too, # automatically (nothing extra to do). Prefixes: NEXT_PUBLIC_ · VITE_ · REACT_APP_ · PUBLIC_ · GATSBY_ · # NUXT_PUBLIC_ · VUE_APP_ · EXPO_PUBLIC_ · BUILD_. SECRETS ARE NEVER BAKED — a secret pasted by hand stays # runtime-only. EXCEPTION (build-time DB access): a var whose VALUE is a reference variable (e.g. # DATABASE_URL='${{db.main.DATABASE_URL}}') IS resolved and injected into the build too — so SSG apps that read # the DB at build time (generateStaticParams/server components) compile. Only refs (own-project resources, # rotatable) reach the build, not hand-pasted secrets. docs/products/BUILD-CONFIG.md + docs/products/ENV-REFS.md # REFERENCE VARIABLES: an env value can wire to another resource in the SAME project — resolved at deploy time # (no copy-pasting connection strings; always current after credential rotation). Syntax: # ${{db..DATABASE_URL}} · ${{redis..REDIS_URL}} · ${{app..URL}} · ${{app..INTERNAL_URL}} · ${{shared.}} # e.g. set DATABASE_URL='${{db.main.DATABASE_URL}}'. Interpolable. Unresolved refs stay literal. docs/products/ENV-REFS.md - DELETE /apps/:id/env?key=NAME — remove an env var + redeploy - GET /apps/:id/deploy-hooks — list deploy hooks (each has a secret `url`) - POST /apps/:id/deploy-hooks {"name","ref"?} — create a deploy hook → returns a secret URL; POST/GET that URL (no auth, the token IS the secret) from CI/CMS/cron to trigger a deploy - DELETE /apps/:id/deploy-hooks/:hookId — delete a deploy hook - GET /apps/:id/protection — Deployment Protection config (mode, scope, allowed IPs) - PUT /apps/:id/protection {"mode":"none|password|trusted_ips","scope":"preview|all","password"?,"allowedIps"?} — protect previews (or prod) with a password or trusted IPs - POST /apps/:id/protection/evaluate {"ip"?,"password"?} — decision allow|unauthorized|block (for agents/tests) - GET /apps/:id/retention — deployment retention policy (keepLast) - PUT /apps/:id/retention {"keepLast":20} — keep the newest N deployments per app (null=keep all); prunes older records ## Rewards (get PAID for using koigrid + feedback — earn balance, redeem as infra credit or gift card) koigrid pays developers for real usage + feedback. Earn balance (USD) per action, spend it as koigrid infra credit OR redeem as a gift card. Scopes rewards:read/write. - GET /rewards — my balance + my submissions - GET /rewards/lines — which actions pay + how much (register/deploy_feedback/report/referral/ugc) - POST /rewards {"reason","details"?} — claim a reward. Gated by VERIFIED usage (feedback of a product you actually used; deploy_feedback needs a real live deploy). Manual approval where it applies. - POST /rewards/redeem {"type":"credit"|"giftcard","amountCents"?} — spend balance as infra credit (no minimum) or gift card ($5 min). ## Cutover manifest — migrate a WHOLE app (front + backend + Redis + Postgres) in ONE command (docs/runbooks/CUTOVER.md) - A `koigrid.yaml` (or .json) declares a PROJECT's resources + their wiring with reference vars, and koigrid brings the whole stack up idempotently (like docker-compose / terraform apply). Scope manifest:write. CLI: `koigrid up`. - POST /manifest — APPLY: creates missing databases/redis/apps, wires ${{db.x.DATABASE_URL}}/${{redis.x.REDIS_URL}}/ ${{app.x.INTERNAL_URL}}, deploys apps in dependency order (backend before web). Idempotent (match by name). CLI: koigrid up [-f koigrid.yaml] - POST /manifest/plan — DRY-RUN: what would be created/updated, without mutating. CLI: koigrid plan [-f koigrid.yaml] - Example koigrid.yaml: `project: my-app` + `databases: [{name: main, memoryMb: 4096}]` + `redis: [{name: cache}]` + `apps: [{name: backend, image: , env: {DATABASE_URL: '${{db.main.DATABASE_URL}}'}}, {name: web, image: , registryAwsRegion: eu-west-2, env: {DATABASE_URL: '${{db.main.DATABASE_URL}}', REDIS_URL: '${{redis.cache.REDIS_URL}}', BACKEND_URL: '${{app.backend.INTERNAL_URL}}'}}]` ## Connect (agent credential broker — ephemeral scoped tokens; vs Vercel Connect) Instead of putting a long-lived token in an app/agent env, put a GRANT (koicg_…) whose ONLY power is to exchange itself for short-lived, scope-limited tokens (STS/AssumeRole style). If the grant leaks, an attacker can only mint short + narrowly-scoped tokens (kill them by disabling the grant), and every exchange is audited (who assumed what, for what purpose, from which IP). Flow: create grant (allowedScopes + maxTtlSeconds ceiling) → at runtime POST /connect/token with the grant secret + requested scopes ⊆ the grant → get {token, expiresAt}. Scopes connect:read/write. - POST /connect/token is the exchange; auth with the grant secret (Authorization: Bearer koicg_…), not a normal token. ## Cost model — how to budget an app BEFORE deploying it (agents: read this to price a workload) - koigrid is FLAT RATE. A plan includes an amount of TOTAL app RAM, split across your apps and replicas however you like — koigrid does NOT charge per replica (Render does: 3 replicas = 3 bills). - Included per plan (total RAM · replicas per app · traffic): Free $0 · 0.5 GB · 1 replica · 100 GB Starter $12 · 2 GB · 3 replicas · 500 GB Pro $35 · 6 GB · 5 replicas · 2 TB Scale $89 · 16 GB · 10 replicas · 5 TB - Above the included RAM: 5 €/GB per month. koigrid does NOT block you — you can always scale (that is how you measure your real peak); you are warned and the excess is billed. - EGRESS IS €0. Traffic out is included, not metered. This is usually the difference that matters: the 5 TB included in Scale would cost ~$500/mo on Render ($0.10/GB) or ~$250/mo on Railway ($0.05/GB). - WORKED EXAMPLE — an app with 3 replicas of 2 GB and 4 TB of traffic per month: RAM = 3 × 2 GB = 6 GB → fits in Pro ($35) with 0 GB of overage. Traffic = 4 TB → needs Scale ($89), whose 5 TB cover it. Final: $89/mo, nothing else. Same workload elsewhere: Render ≈ $75 of instances + ~$400 of egress; Railway ≈ $60 + ~$200 of egress. - YOU set the ceiling: Spend Management (/spend) caps monthly spend and warns at your thresholds, so overage can never surprise you. See docs/products/SPEND.md. ## Container Registry (OCI, ECR-like — docker push/pull, scopes registry:read/write) - Push/pull with DOCKER (not this API): `docker login koigrid.com -u token -p ` then `docker push koigrid.com/:` / `docker pull …`. Standard OCI Distribution under /v2/. Per-org. - GET /registry/repositories — list your image repositories (name + tag count) [management] - GET /registry/repositories//tags — tags of a repo with digest + size + pushed date (ECR-like). CLI: koigrid registry tags - CVE SCANNING (Trivy, ECR image-scanning parity) — every tag push triggers an ASYNC scan (never blocks the push or a deploy); results land in GET /registry/repositories//tags/ as {scan:{status,critical,high,medium, low,findings,error,scannedAt}}. CLI: koigrid registry scan . status is 'scanning' right after push (takes a couple minutes), then 'ok' or 'failed' (with `error`) — a scan is NEVER silently skipped. koigrid does NOT block the deploy on a CRITICAL/HIGH finding: it emits `registry.scan_completed` (severity=warn if any critical/high) to your event feed / webhooks / log drains, same as any other platform event — you decide policy. ⚠️ KNOWN LIMIT — layers over ~100MB fail with "413 Payload Too Large" on push. This is NOT your image being malformed and NOT a quota: koigrid.com sits behind Cloudflare, which caps upload bodies at 100MB, so the request is rejected BEFORE it reaches koigrid. The registry itself streams and accepts blobs up to 5GB — re-verified 2026-07-25: the exact same 184MB layer that 413s through the proxy pushes fine straight to the origin. - RETENTION — what happens to your images over time (an agent MUST know this before relying on an old tag): koigrid keeps the N most recent versions PER REPOSITORY, N by plan: Free 10 · Starter 20 · Pro 50 · Scale 100. ⭐ ABOVE that number, an image is NEVER deleted while something can still use it: if an app is serving it, or you can still roll back to it, it is protected regardless of age. You do not configure that — koigrid computes it, because it is both the registry and the platform that deploys (a standalone registry cannot know what is running, which is why ECR/GitLab make you protect tags by hand with prefixes or regexes). Untagged images (what is left behind when you reuse a tag) are collected once they pass a grace window. The automatic sweep currently runs in DRY-RUN: it reports what it would delete without deleting. What to do TODAY, in order of preference: 1. Keep using your existing registry (ECR/GHCR/Docker Hub) and point koigrid at it — this is fully supported and is what the platform is designed around: POST /apps {sourceType:'image', image, registry, registryUsername, registryPassword} (ECR gets a fresh token per deploy). Nothing is lost by not using koigrid's registry. 2. Slim the image so no single layer exceeds 100MB (multi-stage build, split large COPY layers). Note the limit is PER LAYER, not per image: a 2GB image with 30 small layers pushes fine. Do NOT retry the same push expecting a different result, and do NOT report it as a koigrid outage — it is a known proxy limit with a scheduled fix (serving the registry from a non-proxied hostname). ## Audit Log (who did what — CloudTrail-like, scope audit:read, owner/admin only) - Curated view of the platform event log: security/config CHANGES with an actor (user or agent/token) + source IP. - GET /audit?categories=members,security&types=token.created&actorType=agent&sinceDays=30&limit=100 — list entries - GET /audit/export? — download the audit log as CSV (RFC-4180, formula-injection-safe) - categories: auth, members, tokens, resources, deploy, network, security, billing, config - GET /apps/:id/domains — list custom domains with status (pending/dns_ok/active/misconfigured) + the exact DNS record to create - GET /apps/:id/domains?verify=1 — re-check DNS + cert live and return fresh status (poll this until "active") - POST /apps/:id/domains/verify — after you set the CNAME: re-verify + trigger TLS cert emission now (don't wait for the sweep) - POST /apps/:id/domains {"domain":"app.you.com"} — attach a domain (returns the DNS record to set; apex→ALIAS, subdomain→CNAME). Flow: POST domain → set the CNAME in YOUR DNS → POST /domains/verify → status goes dns_ok → active (cert issued automatically). - DELETE /apps/:id/domains?domain=… — detach a domain - GET /apps/:id/volumes — list persistent volumes (EBS-style block storage) - POST /apps/:id/volumes {"name","sizeGb","mountPath"} — create+attach a persistent volume, then redeploy so the app mounts it at mountPath (data survives deploys) - DELETE /apps/:id/volumes/:volId — detach + delete a volume - GET /apps/:id/volumes/:volId/backups — list volume backups (snapshots) - POST /apps/:id/volumes/:volId/backups — snapshot the volume now (recoverable backup) - POST /apps/:id/volumes/:volId/backups/:backupId/restore {"mountPath"?} — restore a snapshot to a NEW volume - GET /apps/:id/cdn — CDN status (Cloudflare edge cache in front of the app). ON BY DEFAULT for new apps. - PUT /apps/:id/cdn {"enabled":true} — enable/disable CDN (deploy the app first). CloudFront-style. New apps default to cdnEnabled:true (opt out with cdnEnabled:false on create); it auto-activates once the edge cert covers the host, and never breaks TLS before that (serves DNS-only). edge caching + DDoS protection + hides the origin IP. HTML EDGE CACHING (CloudFront-style): the edge caches your HTML documents honoring the ORIGIN's Cache-Control. To make a page cacheable at the edge, respond with Cache-Control: s-maxage= (e.g. s-maxage=60). The bare s-maxage is ENOUGH — the "public" token is NOT required (Next.js ISR never emits it and those pages DO cache; RFC 9111: s-maxage is itself a shared-cache directive). "public, max-age=N" also works. Responses that are private/no-store or carry a Set-Cookie are NEVER cached (auth-safe). Next.js RSC navigation sub-requests bypass the cache (only documents are cached). Apps with Deployment Protection and previews are NEVER edge-cached. No per-app config. ⛔ CUSTOM DOMAINS: an app with an ACTIVE custom domain CANNOT use the CDN, and enabling it returns 400 with the reason. Why: koigrid tells you to CNAME your domain to .apps.koigrid.com; proxying that host would make YOUR domain resolve to Cloudflare's edge for a hostname that is not in koigrid's Cloudflare account, and Cloudflare answers that with error 1014 (CNAME cross-user banned) — i.e. your site DOWN, not merely uncached. So the app stays DNS-only on purpose (event app.cdn.skipped_custom_domain). Choose ONE: (a) serve the app on its .apps.koigrid.com host and get the CDN, or (b) keep your own domain and run without edge caching. Lifting this needs Cloudflare for SaaS (custom ✅ BUT YOU CAN BRING YOUR OWN CDN, and it is measured: koigrid already issues a valid Let's Encrypt cert for your domain on the origin, so putting your own Cloudflare (free plan) in front works with zero changes here. Measured 2026-07-27 on a throwaway app: TTFB 0.350s without CDN → 0.053s with the customer's Cloudflare in front, cf-cache-status HIT = 6.6x faster, plus DDoS, on your account with your cache rules. Steps: CNAME to .apps.koigrid.com → wait for the domain to go 'active' (cert issued) → THEN enable the proxy in your DNS and add a cache rule (cache everything + edge TTL respect origin). Caveats: your origin IP becomes reachable (firewall it to your CDN ranges) and the client IP your app sees becomes the CDN's (read CF-Connecting-IP). Note koigrid will report that domain as 'misconfigured' while it works — its check wants the domain to resolve to OUR IPs. Reproducible: scripts/canary-byo-cdn.ts. Lifting the koigrid-CDN restriction itself needs Cloudflare for SaaS (custom hostnames), which koigrid's zone cannot use today — measured 2026-07-27: the zone is on the Free plan and the API returns error 1404 "No quota has been allocated". Tracked; it is a plan/cost decision, not a missing feature. ## Managed WordPress (1-click WordPress hosting, flat rate — vs BanaHosting/Cloudways) - Scopes: wordpress:read, wordpress:write. Model: an ACCOUNT is the billable unit (an OpenLiteSpeed+LSPHP container with capped RAM/CPU/disk/inodes); inside it you install SITES (a WordPress each: own domain + own database). Pro/Business = unlimited sites per account, bounded honestly by the account's inodes/disk/RAM. - AI-first: you never install a CLI. WP-CLI runs server-side inside the container and you drive the whole WordPress by API — install plugins/themes, update, search-replace. Safe by construction: allowlist-first (unlisted commands are denied), with eval/eval-file/shell/db/config/package/server/cli denied outright, and argv is passed to the container without a shell (metacharacters in a value are inert). - GET /wordpress/accounts — list hosting accounts - POST /wordpress/accounts {"slug","name","planTier"?(starter|pro|business),"phpVersion"?,"projectId"?} — create an account. Provisioning is async: poll status (provisioning → running). - GET /wordpress/accounts/:id — account detail (plan caps, status, runner) - DELETE /wordpress/accounts/:id — delete an account (and its sites). Scope wordpress:write - GET /wordpress/accounts/:id/sites — list the WordPress sites in an account - POST /wordpress/accounts/:id/sites {"slug","title","adminUser","adminEmail","domain"?,"phpVersion"?,"wpVersion"?} — 1-click install a WordPress. wpVersion: "latest" (default) or an exact version ("6.8.1"). Returns the admin password ONCE. Install is async: poll status (installing → running). Without a domain it is served on a koigrid subdomain — install there, test it, point your own domain when ready. - GET /wordpress/sites/:id — site detail (never returns secrets) - DELETE /wordpress/sites/:id — delete a site (its database and docroot go with it) - POST /wordpress/sites/:id/wp-cli {"command"} — run a sanitized WP-CLI command (e.g. "plugin install woocommerce --activate"). Returns exitCode + stdout. Scope wordpress:write - GET /wordpress/sites/:id/backups — list backups of a site - POST /wordpress/sites/:id/backups — back up a site now (database dump + files). Scope wordpress:write ## Managed databases (highly-available Postgres, auto-failover) - Scopes: databases:read, databases:write. HA (leader + replicas, Patroni auto-failover). - GET /databases — list databases - POST /databases {"name","version"?(14-17),"replicas"?,"memoryMb"?,"cpus"?,"diskGb"?} — create a database (HA per plan; SIZE not capped by plan: request the RAM/CPU/DISK you need — e.g. 40GB to migrate a 31GB RDS on Free). DISK IS ELASTIC: the response's diskGb is the plan FLOOR (what's included), NOT a cap — the DB grows on demand with overage (~$0.05/GB·mo). The response also echoes diskFloorGb + diskElastic:true so diskGb:1 is never misread as "hard-capped at 1GB". ⚠️ SIZE RAM AT CREATE for bulk restores: a >10GB pg_restore needs ≥4GB RAM (memoryMb:4096) or the DB can crash under load — there's no resize after create yet. - GET /databases/:id — detail (status, members with role leader/replica) - PATCH /databases/:id {"name"} — rename the database (metadata; connection unchanged) - PATCH /databases/:id {"backupRetentionDays":N} — how many days of backups to keep (1-35, default 7). This is the knob that controls both your point-in-time recovery window AND what your backups cost in storage. ⚠️ LOWERING it deletes: the next prune removes backups older than the new window and that cannot be undone, so the response carries a "warning" field saying exactly that. Raising it is free of risk (nothing is deleted) but the extra history builds up from now on — it does not resurrect backups already pruned. CLI: koigrid db retention [days] (no days = show current) - DELETE /databases/:id — delete a database (tears down all members + router) ⭐ FINAL SNAPSHOT: deleting a database takes a FULL final backup FIRST (RDS "delete with final snapshot"), kept INDEFINITELY — not just for the retention window — so you can bring the database back later with POST /databases/:id/restore using the DELETED cluster's id as the source. Skip it with ?finalSnapshot=false (CLI: koigrid db rm --no-final-snapshot) when the data is genuinely disposable. The delete returns immediately; the snapshot runs in the background BEFORE the teardown (events db.final_snapshot.done / .failed). - GET /databases/:id/connection — resp.connection {uri, host, port, database, username, caCert (PEM), sslVerifiedUri}. Connect TLS-verified with ssl:{ca:caCert} on sslVerifiedUri (sslmode=verify-ca) → no rejectUnauthorized:false hack. The port is DYNAMIC per cluster (not a fixed 6432). readUri (read replica endpoint) exists ONLY on HA plans; Free single-node has none. - GET /databases/:id/metrics — operational health metrics (connections, replication lag, disk, size) - GET /databases/:id/backups — backup ledger (each row carries mode=full|delta and why; WAL archiving = PITR) - POST /databases/:id/backups {"full"?:true} — trigger an on-demand backup. Daily backups are INCREMENTAL with a weekly full (a delta only restores together with its chain); pass full:true to force a standalone full — worth doing before a migration or a mass delete, so you hold a base that does not depend on the chain - POST /databases/:id/restore {"name"?,"targetTime"?} — restore to a NEW cluster from backups (targetTime = ISO 8601 for point-in-time recovery / PITR) - MANAGED RESTORE (import a .sql dump, "migrate in an afternoon"): 3 steps → 1) POST /databases/:id/restore-dump/upload-url {"gzip":true} → {uploadUrl, key, maxBytes}; PUT your dump to uploadUrl. ⭐ ALWAYS gzip it (gzip -9 dump.sql → upload the .sql.gz, set gzip:true): a dump compresses ~8×, so a 31 GB dump becomes ~3.8 GB and fits well under maxBytes (default 20 GiB). koigrid decompresses on restore. Over maxBytes the restore is REJECTED with the exact size + how to fix it. The CLI gzips+streams for you. ⏱️ TIME LIMIT (stated here on purpose): **a restore is never killed for being slow — only for stopping.** While it grows or is executing anything, it is left to finish, however long that takes. A progress watchdog samples the database from a SEPARATE session once a minute; if it neither grows a byte nor runs anything for 20 minutes, it is cut with error `restore_stalled`, which reports how long it had been still and how big the database was. There is also an absolute ceiling of 6 h (a net for a hung process, not a product limit → `restore_timeout`). Either way the transaction rolls back and the database is left EXACTLY as it was; the space is reclaimed on its own in seconds (no VACUUM needed) and you can retry without recreating the cluster. The size-based figure you may see in events (~170 MB of plain SQL per minute) is an ESTIMATE to orient you, not a deadline. (History, because both halves matter: until 2026-07-30 the cap was a fixed, UNDOCUMENTED 30 min, so a 33 GB database could not use this endpoint at all. It was then derived from size — and that same day it still killed a live customer migration at exactly 30 min WHILE IT WAS STILL WORKING, because a dump with pgvector indexes restores far slower than the constant assumed. Any constant is wrong for someone, so the clock stopped deciding.) 2) POST /databases/:id/restore-dump {"dumpKey": key, "preSeed"?: [{"name":"vector","schema":"extensions"}]} → async job. preSeed pre-creates the extension in the right schema BEFORE restore (fixes pgvector/Supabase dumps that expect extensions.vector). Restore is fail-fast (ON_ERROR_STOP → the ROOT-CAUSE error, not a cascade), direct to the leader (not the pooler → COPY works). 3) GET /databases/:id/restore-dump/:jobId → wrapped as {"restore":{...}} (NOT {"job":...}): read resp.restore.status (queued|seeding|restoring|done|failed) + resp.restore.tableCounts (per-table row counts to verify the migration) + error (root cause if failed). CLI: koigrid db restore-dump --file dump.sql [--pre-seed vector:extensions] [--wait] After a successful restore koigrid AUTOMATICALLY makes your app role the OWNER of everything restored (+ grants + default privileges), so the connection-string user can read/write immediately. For databases left unusable by an OLDER restore (owner=postgres → "permission denied for table"), run: POST /databases/:id/fix-ownership (idempotent; CLI: koigrid db fix-ownership ). Dumps from pg_dump >= 17.6/18 (which wrap output in \restrict/\unrestrict) are supported — koigrid strips those meta-commands. Dumps containing psql meta-commands that run programs or read server files (\!, \i, \copy…) are REJECTED for safety, and a dump made with pg_dump -C/--create (it carries \connect) is rejected too — re-dump without --create. RETRY IS CHEAP: if a restore FAILS, the uploaded dump is KEPT — re-POST /restore-dump with the SAME dumpKey instead of re-uploading (it is only deleted after a SUCCESSFUL restore, or swept a day later). And the restore is ATOMIC (single transaction): a failure leaves the database exactly as it was, so the next attempt reports the SAME root cause instead of "relation X already exists" from a half-done attempt. Only ONE restore runs per database at a time (a 2nd concurrent one is rejected with the running job id — two dumps applied at once would interleave). Restore into an EMPTY database: the dump's CREATE TABLE will fail fast if the objects already exist (that failure is safe — it stops before writing). - GET /databases/:id/replicas — current read-replica count + plan max - POST /databases/:id/replicas {"replicas":N} — scale read replicas HOT, no downtime (0..plan max); new replicas stream from the leader and join the read endpoint (readUri) - GET /databases/:id/branches — branch tree: { production, branches[] }. A branch = a full-copy CHILD clone of the production database (real cluster, counts vs plan). - POST /databases/:id/branches {"name","fromTimestamp"?,"ttlHours"?,"protected"?,"schemaOnly"?} — create a branch. Clones production NOW (or PITR to fromTimestamp). ttlHours = ephemeral (auto-expires). schemaOnly:true → copies ONLY the structure (DDL, no data): fast/cheap, no PII in the test env. Use its connection via GET /databases/:branchId/connection or a reference var. - PATCH /databases/:id/branches/:branchId {"protected":bool} — protect a branch from deletion - POST /databases/:id/branches/:branchId/reset — reset the branch to production's CURRENT state via an ATOMIC SWAP (old branch keeps serving until the fresh clone is healthy, then cuts over; a failed reset never breaks the branch). Same branch id; connection re-points to the new clone. - DELETE /databases/:id/branches/:branchId — delete a branch (never production/protected; keeps backups) - Logical replication ENABLED (wal_level=logical) + the app user has REPLICATION and can CREATE SUBSCRIPTION (PG16+) → near-zero-downtime CDC migration into koigrid. - POST /databases/:id/rpc/:fn {"args":{name:val},"write"?} — Data API RPC: call a Postgres function (PostgREST /rpc). Read-only by default (databases:read); write:true for mutating funcs (databases:write). - GET /databases/:id/roles — list Postgres roles (name, canLogin, createdb…) - POST /databases/:id/roles {"name","password","login"?,"createdb"?} — create a LEAST-PRIVILEGE role (never superuser/createrole). Password over stdin, never logged. Use for GRANT + SET ROLE (scoped access within the app-user session) + object ownership. (Per-role external login via the pooler = auth_query, coming later.) - PATCH /databases/:id/roles/:roleName {"password"} — change a role's password - DELETE /databases/:id/roles/:roleName — drop a role (never the app user) - GET /databases/:id/rotation — automatic password rotation policy + state (AWS Secrets Manager rotation substitute). Alternating DUAL-ROLE: two app roles take turns as active; rotating prepares the standby with a new password and flips it, the previous stays valid until the NEXT rotation (a full interval of overlap → ZERO-DOWNTIME). - PUT /databases/:id/rotation {"intervalDays"?,"graceDays"?} — set the policy. intervalDays 0/null = auto OFF (manual only), >0 = rotate every N days. Enabling auto-rotation REQUIRES the pooler in reloadable FILE mode → migrate first (409 otherwise). Scopes databases:read/write. - POST /databases/:id/rotation/migrate — migrate the pooler from legacy env mode to reloadable file mode (SIGHUP, no restart; brief reconnect). Required once before enabling rotation. - POST /databases/:id/rotation/rotate — rotate the password NOW (dual-role flip, zero-downtime). Requires file mode. Apps wired via ${{db.x.DATABASE_URL}} pick up the new credential on redeploy. - GET /databases/:id/data/:table — Data API (PostgREST-style REST over tables). ?select=a,b &limit=N &offset=N &order=col[.desc] &=. (op: eq/neq/gt/gte/lt/lte/like/ilike; bare=eq). Read as the app user (read-only). Identifiers validated, values PARAMETERIZED. - POST /databases/:id/data/:table {"rows":[{...}]} — insert rows RETURNING *. Scope databases:write. AI-first: read/write your data over HTTP without a Postgres driver. - PATCH /databases/:id/data/:table {"set":{col:val}} + ?col=op.val filters (≥1 REQUIRED) — update rows RETURNING *. - DELETE /databases/:id/data/:table with ?col=op.val filters (≥1 REQUIRED — never the whole table) — delete rows RETURNING *. - GET /databases/:id/query — list tables (data browser). Scope databases:read - POST /databases/:id/query {"sql":"select …","write":false} — run SQL as the app user. Read-only by default (forced READ ONLY transaction); write:true needs databases:write. Returns {columns,rows,rowCount,truncated}. SQL console - GET/POST/DELETE /databases/:id/publications — manage logical-replication publications (CDC OUT of koigrid to a warehouse/Debezium/another DB). POST {"name","allTables":true} or {"name","tables":["t1","t2"]}; koigrid runs it as superuser on your behalf so FOR ALL TABLES works (app user can't do that in Postgres). - PostgreSQL VERSIONS: 15, 16, 17 (POST /databases {"version":"17"}; default 17). Pick the SAME major as your source — koigrid never downgrades your major. What you CANNOT pick is the minor: you get the one in koigrid's pinned image (today PG17 = 17.5), so if your source runs a newer patch release, say so before migrating rather than discovering it after the restore. Unlike RDS there is no per-minor selection and no auto minor upgrade yet: an existing cluster keeps the patch level it was created with until it is re-provisioned. GET /databases/:id now returns "postgres": {running, available, behind, note} — the version your cluster is ACTUALLY on, what koigrid ships today for that major, and whether you are behind. Check it after a migration: it is the only way to see your real patch level, and "behind" will stay true until a re-provision. - pgvector is PRE-INSTALLED (CREATE EXTENSION vector; CREATE TABLE ... vector(N)) — AI-native, no superuser needed. Common trusted extensions (pg_trgm, uuid-ossp, pgcrypto, unaccent, pg_stat_statements, hstore, citext) are installable by the app user with CREATE EXTENSION. Verified: a real 99-table schema migrated. - ⭐ MIGRATING FROM SUPABASE? Supabase installs extensions in an 'extensions' schema, so its dump declares types as extensions.vector. koigrid pre-installs vector in 'public', which would break the restore ("type extensions.vector does not exist"). BEFORE restoring the dump, run: POST /databases/:id/extensions {"name":"vector","schema":"extensions"} (CLI: koigrid db ext vector --schema extensions) — koigrid (as superuser) RELOCATES pgvector into the 'extensions' schema AND adds it to the DB search_path, so both the extensions.vector columns AND the operators (<=>, <->) work. Do the same for any other extension your dump expects there (uuid-ossp, pgcrypto…). Then restore. Verified end-to-end against real pgvector. - Disk is sized per plan (diskGb on create); multi-GB databases are supported — no hard row/size cap beyond disk. - The endpoint (db-.dbs.koigrid.com) always routes writes to the current leader; on failover the endpoint does not change. TLS required (sslmode=require). ## Managed cache / KV (Redis, HA with Sentinel — AWS ElastiCache replacement) - Scopes: redis:read, redis:write. HA (master + replicas, Sentinel auto-failover). - GET /redis — list caches - POST /redis {"name","version"?,"maxmemoryPolicy"?,"replicas"?} — create a cache (HA per plan) - PATCH /redis/:id {"name"} — rename the cache (metadata; connection unchanged) - GET /redis/:id — detail (status, members with role master/replica) - DELETE /redis/:id — delete a cache (tears down members + sentinels + router) - GET /redis/:id/connection — connection string (rediss://:pass@endpoint → current master) - GET /redis/:id/metrics — operational health metrics (used memory, %, clients) - GET /redis/:id/backups — RDB snapshot ledger - POST /redis/:id/backups — trigger an on-demand RDB snapshot to object storage - POST /redis/:id/restore {"name"?} — restore to a NEW cache from a fresh snapshot of this one - The endpoint (rds-.rds.koigrid.com) always routes to the current master; on failover it does not change. TLS terminated at the router (rediss://). Auth via password (requirepass). ## Transactional email (AWS SES replacement) — BYO (bring your own SMTP) - Scopes: email:write (config), email:send. You bring YOUR OWN provider (Resend/SES/Postmark/SMTP); koigrid relays through it (no shared reputation, no per-domain limits). - PUT /email/config {"smtp":"smtp://user:pass@smtp.provider.com:587","fromDefault"?:"You "} — set your SMTP credentials (verified on save, stored encrypted) - GET /email/config — config status (SMTP masked) · DELETE /email/config — remove - POST /email/send {"to":"...","subject":"...","html"|"text":"...","from"?:"..."} — send via your SMTP - GET /email/messages — send ledger ## Cron / scheduled jobs (AWS EventBridge/cron replacement) - Scopes: jobs:read, jobs:write. A job runs a container image on a cron schedule. - GET /jobs — list cron jobs - POST /jobs {"name","schedule","image","command"?,"timezone"?,"env"?,"memoryMb"?,"cpus"?,"timeoutSec"?,"maxRetries"?,"enabled"?} — create a CONTAINER job. schedule = 5-field cron (min hour dom mon dow) - POST /jobs {"name","schedule","type":"http","url","method"?,"headers"?} — create an HTTP job: koigrid hits the URL on schedule (most crons just call an endpoint). headers encrypted (put your CRON_SECRET there). CLI: koigrid jobs create x --schedule "…" --url … - GET /jobs/:id — detail (schedule, next/last run, last status) - PATCH /jobs/:id {…} — update schedule/image/command/enabled/… (recomputes next run) - DELETE /jobs/:id — delete a job (and its run history) - GET /jobs/:id/runs — run ledger (status, exit code, duration, logs) - POST /jobs/:id/trigger — run the job now (manual), returns the run - Command (if set) runs via "sh -c" inside the image; leave empty to use the image entrypoint. Jobs run "docker run --rm" on a runner with your memory/cpu limits and a hard timeout. ## Observability (logs/metrics/alarms — AWS CloudWatch replacement) - Scopes: observability:read, observability:write. - GET /events?type=&severity=&entityType=&entityId=&sinceHours=&limit= — your org activity timeline (app/db/job/deploy/… events) - GET /metrics?days=30 — daily usage time series per metric (+ synthetic cost_cents) - GET /alarms — list alarms - POST /alarms {"name","metric","comparator"?,"threshold","windowDays"?,"resourceType"?,"resourceId"?,"enabled"?} — alarm on usage OR on a resource health metric (resourceType database|redis + resourceId + metric like redis_used_memory_pct) — create an alarm (metric of usage_counter or "cost_cents"; comparator gt|gte|lt|lte). Fires event alarm.triggered. - DELETE /alarms/:id — delete an alarm - GET /anomalies — list anomaly detectors (statistical spike detection, no fixed threshold) - POST /anomalies {"name","metric"?,"sigma"?,"lookbackDays"?,"minValue"?,"enabled"?} — watch a metric ("cost_cents" default); alerts when a day deviates >sigma σ from its recent baseline. Fires event anomaly.detected. - DELETE /anomalies/:id — delete an anomaly detector - GET /checks — list uptime & synthetic monitoring checks (status, latency) - POST /checks {"name","target":"https://you.com/api/health","type":"url"|"api","intervalSeconds":300, "assertions":[{"type":"status","value":"2xx"},{"type":"latency","value":800},{"type":"body_contains","value":"ok"}], "headers":{"Authorization":"Bearer …"},"webhookUrl":"https://hooks.slack.com/…","alertAfter":2} — create a check (URL/API with assertions). Alerts owner by email + webhook on down/recovery. - GET /checks/:id — check detail - PATCH /checks/:id — update a check - DELETE /checks/:id — delete a check - POST /checks/:id/run — run the check NOW (run-once) → {result:{status,httpStatus,latencyMs,assertionResults}} - GET /checks/:id/runs — run history + metrics (uptime %, p95 latency) ## Webhooks (outbound: your platform events → your URL, signed + retried; vs Svix) - GET /webhooks — list outbound webhooks - POST /webhooks {"url","events":["*"|"app.*"|"db.failover"],"description"?} — create; RETURNS {secret} ONCE (HMAC signing key) - PATCH /webhooks/:id {"enabled":bool} — enable/disable a webhook - DELETE /webhooks/:id — delete a webhook - GET /webhooks/:id/deliveries — delivery history (status, attempts, response code, error) # koigrid POSTs {type,data,timestamp}; verify header X-Koigrid-Signature = "sha256="+HMAC_SHA256(secret, rawBody). # Retries with backoff (5 attempts, ~2h). Destinations are validated against SSRF (no internal/private hosts). ## Log Drains (outbound: forward your logs to an external sink; vs Vercel Log Drains) - GET /log-drains — list log drains - POST /log-drains {"name","type":"http"|"datadog","url"?,"headers"?:{…},"sources"?:["build","runtime","app","system"]} # url optional for datadog (defaults to DD intake); headers (auth) stored encrypted; sources omitted = all - PATCH /log-drains/:id {"enabled":bool} — enable/disable - DELETE /log-drains/:id — delete a drain - POST /logs {"records":[{"message","level"?,"source"?,"appId"?,"ts"?}]} — ingest structured logs (→ 202); forwarded to drains - GET /logs?appId=&source=&limit= — query recent buffered logs # Delivery: durable buffer + per-drain cursor (a sink that's down resumes without loss). Destinations validated anti-SSRF. ## Spend Management (monthly spend cap + threshold alerts + optional hard stop; vs Vercel Spend Management) - GET /spend — current month spend + limit + pct + over/blocked - PUT /spend {"amountCents","thresholds"?:[80,100],"hardStop"?:false} — set the monthly limit (upsert) - DELETE /spend — remove the limit # Each crossed threshold emails the owner + emits an event (spend.threshold / spend.limit_reached → webhookable). # hardStop=true blocks creating new PAID resources (apps/databases/caches) at 100% → 402; reads/running stay up. ## Bot Management (classify bots + challenge/block; vs Cloudflare Bot Management / Vercel BotID) - GET /bots/policy — current bot policy - PUT /bots/policy {"mode":"off"|"log"|"challenge"|"block","allowVerified":true,"challengeDifficulty":4} - POST /bots/evaluate {"userAgent","path"?} → {action:"allow"|"challenge"|"block", category, botName} - POST /bots/challenge — issue a proof-of-work challenge → {challenge, difficulty} - POST /bots/verify {"challenge","solution"} → {ok:true|false} # Verified crawlers (Googlebot/Bingbot/GPTBot…) are allowlisted by default (allowVerified) so SEO never breaks. # PoW: find solution s.t. sha256(salt+solution) starts with "difficulty" hex zeros. Your edge enforces the decision. ## OpenTelemetry export (ship metrics in native OTLP to any OTEL backend; vs Vercel OpenTelemetry) - GET /otel — current OTLP metrics exporter config - PUT /otel {"endpoint":"https://otlp.grafana.net/v1/metrics","headers"?:{…},"enabled"?:true} # endpoint validated anti-SSRF; headers (auth) stored encrypted - DELETE /otel — remove the exporter # koigrid pushes OTLP/JSON metrics every few minutes: koigrid.usage{metric=…} + koigrid.cost. Open protocol, no lock-in. ## DNS Management (BYO provider: manage records for your own zones; vs Vercel DNS / Cloudflare DNS) - GET /dns/zones — list connected zones - POST /dns/zones {"provider":"cloudflare","domain","zoneId","apiToken"} — connect a zone (token stored encrypted) - DELETE /dns/zones/:id — disconnect a zone - GET /dns/zones/:id/records — list records - POST /dns/zones/:id/records {"type":"A|AAAA|CNAME|TXT|MX|NS|CAA","name","content","ttl"?,"priority"?,"proxied"?} - PUT /dns/zones/:id/records/:recordId {…} — update a record - DELETE /dns/zones/:id/records/:recordId — delete a record # BYO: your zone lives in your provider account (no lock-in). v1 = Cloudflare. Records validated before write. ## Queues (durable message queue, SQS-like — scopes queues:read/write) - GET /queues — list queues - POST /queues {"name","visibilityTimeoutSec"?,"maxReceives"?} — create a queue - GET /queues/:id — queue detail + stats (pending / in-flight / dead) - PATCH /queues/:id {"visibilityTimeoutSec"?,"maxReceives"?} — reconfigure the queue (no recreate) - DELETE /queues/:id — delete a queue - POST /queues/:id/messages {"body","delaySeconds"?,"dedupeId"?} — send a message (dedupeId = idempotent) - POST /queues/:id/receive {"max"?,"visibilityTimeoutSec"?} — claim up to N messages (hidden while processed); returns each with a receiptHandle. At-least-once: without ack it reappears. - POST /queues/:id/ack {"receiptHandle"} — delete a processed message. After maxReceives → dead-letter. ## Workflows (durable execution, Step-Functions-like — scopes workflows:read/write) - GET /workflows — list workflows - POST /workflows {"name","steps":["a","b"],"maxAttempts"?,"stepTimeoutSec"?} — define ordered steps - GET /workflows/:id — workflow detail - PATCH /workflows/:id {"maxAttempts"} — reconfigure the workflow (no recreate; steps unchanged) - DELETE /workflows/:id — delete a workflow - POST /workflows/:id/runs {"input"?} — start a run (durable; survives crashes/redeploys) - GET /workflows/:id/runs — list runs - GET /workflows/:id/runs/:runId — run detail (status, currentStep, journal) - POST /workflows/:id/runs/:runId/next — WORKER: claim the next step (leased). Returns {step,context,input} or {done} / {waiting}. Completed steps are never re-run (journaled). - POST /workflows/:id/runs/:runId/complete {"step","output"?} — report the step result → advances the run - POST /workflows/:id/runs/:runId/fail {"step","error"?} — step failed → retry with backoff, or run fails - POST /workflows/:id/runs/:runId/sleep {"seconds","lease"} — durable sleep (seconds to 90 days), survives restarts. lease = the token from next (a zombie worker can't sleep a re-claimed run) ## Members / Team (multi-tenant: invite teammates and manage roles) - GET /members — list team members + pending invitations - POST /members {"email","role"} — invite by email (role: admin|member, default member); sends an email invite - PATCH /members/:userId {"role"} — change a member's role (admin|member; not the owner) - DELETE /members/:userId — remove a member (not the last owner) - DELETE /members/invitations/:id — revoke a pending invitation Roles: owner (full), admin (manage members + resources), member (use resources). Only owner/admin can manage. ## Feature Flags (toggle features + gradual rollout, evaluated stably per user) - GET /flags — list feature flags (project-scoped) - POST /flags {"key":"checkout-v2","enabled":true,"rolloutPercent":10} — create a flag - GET /flags/:key — flag detail - PATCH /flags/:key {"enabled":true} — toggle, or set {"rolloutPercent":25}, {"rules":[…]}, or description - DELETE /flags/:key — delete a flag - POST /flags/evaluate {"context":"user-42","attributes":{"plan":"pro"}} — evaluate ALL flags → {flags:{key:bool}}. The 'context' (a stable user id) makes the rollout consistent; 'attributes' drive targeting rules. Targeting: a flag's rules=[{"attribute":"plan","op":"in"|"not_in","values":["pro"]}] must ALL match (AND). SDK: npm i @koigrid/flags → createFlagsClient({token}).enabled('checkout-v2', userId) (caches per context). ## Status Pages (public status page generated from your Checks) - GET /status-pages — list status pages - POST /status-pages {"slug":"acme","name":"Acme Status","checkIds"?:[…]} — create (checkIds null = all project checks) - GET /status-pages/:id — detail - PATCH /status-pages/:id — update name/description/checkIds - DELETE /status-pages/:id — delete - GET /status-pages/:id/incidents — list incidents/maintenance of a page - POST /status-pages/:id/incidents {"title","impact":"minor|major|critical","status":"investigating|identified|monitoring|resolved","body"?} — post an incident (shown on the public /status/ page) - PATCH /status-pages/:id/incidents/:incidentId {"status":"resolved","body"?} — update/resolve an incident The page is PUBLIC (no auth) at /status/ and shows each Check as a component (name + status + uptime). Scopes: status:read, status:write. ## Heartbeats / Cron Monitoring (dead-man's switch: alert when a cron stops pinging) - GET /heartbeats — list heartbeats - POST /heartbeats {"name":"Nightly backup","periodSeconds":86400,"graceSeconds":600} — create → returns pingUrl - GET /heartbeats/:id — detail - PATCH /heartbeats/:id — update name/period/grace/webhook - DELETE /heartbeats/:id — delete Your cron pings the pingUrl on success: curl -fsS https://koigrid.com/ping/ (public, no auth — the token is the secret). If no ping arrives within period+grace, the heartbeat goes down and alerts (email + webhook). Scopes: heartbeats:read, heartbeats:write. ## Config / Secrets (shared, encrypted project config — the Doppler alternative) - GET /config — list keys (values MASKED, never plaintext) - PUT /config {"key":"DATABASE_URL","value":"…"} — set a value (encrypted at rest) - GET /config/pull — pull ALL config decrypted → {config:{key:value}} (for your app/agent) - DELETE /config/:key — delete a key Shared across all apps in the project (vs per-app env vars). Values are encrypted (envelope) and never appear in logs/events (only the key name). Scopes: config:read, config:write. ## Web Analytics (privacy-first, no cookies — the Plausible alternative) - GET /analytics/sites — list sites - POST /analytics/sites {"name":"my-site.com"} — create → returns the beacon snippet + publicToken - GET /analytics/sites/:id — site detail - DELETE /analytics/sites/:id — delete a site + its events - GET /analytics/sites/:id/stats?days=30 — { pageviews, visitors, topPaths, topReferrers } Add the snippet to your site: . The beacon POSTs to /api/collect (public). No cookies — a visitor is a daily hash of ip+ua (no PII). Scopes: analytics:read, analytics:write. ## Firewall / WAF (allow/block rules + a decision endpoint — the Cloudflare WAF alternative) - GET /waf/rules — rules by priority - POST /waf/rules {"action":"block","matchType":"country","matchValue":"RU","priority":100} matchType ∈ all | ip | ip_cidr | country | path_prefix | method | ua_contains RATE LIMIT: {"action":"rate_limit","matchType":"path_prefix","matchValue":"/api","rateLimit":100, "rateWindowSec":60,"rateKey":"ip"} → over 100 req/60s per IP on /api → evaluate returns block+rateLimited - PATCH /waf/rules/:id {"enabled":false} · DELETE /waf/rules/:id - POST /waf/evaluate {"ip":"…","country":"…","path":"…","method":"…","userAgent":"…"} → {action, ruleId} Your edge/middleware/agent calls /waf/evaluate per request to decide (first matching rule wins; default allow). Scopes: waf:read (list, evaluate), waf:write (add/toggle/delete rules). ## Image Optimization (resize/re-encode on the fly — the Cloudinary/Vercel Images alternative) - GET /images/config — { publicToken, allowedHosts, imgBase } - PUT /images/config {"allowedHosts":["cdn.example.com","*.example.com"]} — set the anti-SSRF allowlist - GET /images/usage?days=30 — requests, bytes in/out, bytes saved Optimize (public): Only allowlisted hosts are fetched; private/internal IPs are always blocked. Scopes: images:read, images:write. ## Sandboxes (ephemeral compute — run code once, get output) - POST /sandboxes {"image":"alpine:3.19","command":"echo hi","timeoutSec":30,"memoryMb":256} → runs the image+command ONCE (docker run --rm), returns { status, exitCode, logs, durationMs } - GET /sandboxes — recent runs - GET /sandboxes/:id — one run's detail Ideal for agents running untrusted code. Isolated, memory/time-limited. Scopes: sandboxes:read, sandboxes:write. ## Speed Insights (Core Web Vitals — the Vercel Speed Insights alternative) - GET /speed-insights — all sites with p75 per metric (LCP/CLS/FCP/TTFB) (?days=30) - GET /speed-insights/:siteId — one site: { metrics: [{ metric, p75, samples, rating }] } Add the vitals beacon (reuses the Web Analytics site token, no cookies): Real-user metrics POST to /api/vitals (public). Read scope: analytics:read. ## Error Tracking (capture + group exceptions — the Sentry alternative) - GET /errors — { ingestToken, groups } (groups: message, level, count, resolved) - PATCH /errors/groups/:id {"resolved":true} — resolve or reopen a group - DELETE /errors/groups/:id — delete a group Capture errors from your app (public, by ingest token): POST https://koigrid.com/api/errors {"token":"…","message":"…","stack":"…","level":"error"} Identical errors (same message + top stack frame) group into one, with a count. Scopes: errors:read, errors:write. Scopes: flags:read, flags:write. - GET /usage — usage vs free tier + token budget ## Storage (S3-compatible) Two ways in, and you pick by who needs the access: · **S3 protocol** — for your servers, aws-cli, rclone or any S3 SDK. Needs a key (below). · **Signed URLs via this API** — for browsers and mobile apps. No credentials ever leave your backend, and the bytes never pass through koigrid's API: the client talks straight to storage. ### Full workflow POST /buckets {"name":"videos"} → {bucket:{id, name, bucketName, backend}} GET /buckets → your buckets (name, bucketName, backend) GET /buckets/:id/objects?prefix=fotos/ → {folders:[…], objects:[{key,size,lastModified}], nextToken} POST /buckets/:id/objects/upload-url {"key":"a/b.mp4","contentType":"video/mp4"} → {url, key} GET /buckets/:id/objects/download-url?key=a/b.mp4 → {url} DELETE /buckets/:id/objects?key=a/b.mp4 → soft delete (recoverable, see versions) GET /buckets/:id/versions?prefix=… → previous versions + delete markers POST /storage/keys → {credential:{accessKey, secretKey, endpoint}} (secret shown ONCE) GET /storage/keys · DELETE /storage/keys/:id Signed URLs: `PUT` the file to the upload url as-is, `GET` the download url. They expire (15 min upload, 1 hour download) and carry their own auth — do not add an Authorization header to them. ### Three things that will bite you if you skip them 1. **Use `bucket.bucketName`, not `bucket.name`**, as the Bucket in S3 calls. The friendly name is a label; the physical name is what S3 knows. Using the wrong one returns NoSuchBucket. 2. **Take the endpoint from the response of POST /storage/keys — never hardcode one.** Buckets can live on different storage fleets, and each has its own address; the key you get back is only valid on the address that comes with it. `bucket.backend` tells you which fleet a bucket is on. 3. **Set `forcePathStyle: true`** (path-style addressing). Any region string works. ### Notes - A key is scoped to the buckets your organization owns — it cannot see anyone else's, and it keeps working for buckets you create later. - Objects are versioned: overwriting or deleting a file does not destroy it. Recover with the versions endpoint. - Storage and bandwidth are included per plan; overage is billed at a flat per-GB rate. - Scopes: storage:read, storage:write. ## Share (upload a file, get a link) - POST https://koigrid.com/api/share — upload a file, returns its public link. - Anonymous: up to 25 MB, link expires in 2h. With a token: up to 100 MB, 30 days. - curl (anonymous): curl -F file=@photo.png https://koigrid.com/api/share - curl (with token): curl -H "Authorization: Bearer koi_YOUR_KEY" -F file=@big.zip https://koigrid.com/api/share - Returns the URL as plain text (or JSON if header `Accept: application/json`). - The link https://koigrid.com/s/ serves the file (images/PDF shown inline) until it expires. - Image transforms (Cloudinary-style): https://koigrid.com/s/?w=400&h=300&fit=cover&format=webp&q=80 — resize/convert/quality on the fly, cached, bandwidth included. Manage files in the dashboard (Files). ## Links (short links + QR codes) - POST https://koigrid.com/api/links — shorten a URL. Body: the URL (raw, form url=, or JSON {"url":...}). - With a Bearer token the link is permanent and editable (dynamic QR); anonymous expires in 90 days. - curl: curl -H "Authorization: Bearer koi_YOUR_KEY" -d "https://example.com/long" https://koigrid.com/api/links - Returns the short URL https://koigrid.com/l/ (plain text, or JSON if Accept: application/json). - GET https://koigrid.com/api/qr?data=&size=512 — PNG QR code for any URL or text (also the link's QR). ## Apps (deploy a container by API) - From an image: curl -H "Authorization: Bearer koi_YOUR_KEY" -H "Content-Type: application/json" \ -d '{"name":"web","sourceType":"image","image":"nginx:latest","port":80}' https://koigrid.com/api/v1/apps - From a git repo (built for you — Dockerfile or auto-detected with nixpacks): -d '{"name":"web","sourceType":"git","repo":"https://github.com/you/app","port":3000}' - Deploy: POST https://koigrid.com/api/v1/apps//deployments — returns the deployment (status: queued→building→deploying→live|failed, plus "url" and "runner"). - Live apps are served at https://.apps.koigrid.com with automatic HTTPS. Attach your own domain with POST /apps/:id/domains (returns the CNAME to point at us). - Git repos are built for you: with a Dockerfile it builds sandboxed (kaniko in gVisor); without one, nixpacks auto-detects the language. Containers run isolated (gVisor). Read build+runtime logs, roll back to any prior deployment, and add custom domains — all by API. - Scopes: creating needs apps:write, deploying needs apps:deploy, reading apps:read. ## What changed recently (check this before re-running your tests) - https://koigrid.com/api/v1/changelog — public, no auth. `?since=YYYY-MM-DD` returns only what changed after that date, so you can ask "anything new since my last run?" instead of re-testing everything blind. `latest` is the date of the newest change: compare it with your last check to know if there is anything to read at all. - Each entry has `kind` (fix · feature · breaking · docs) and, when it applies, the `endpoints` it affects — so you can filter down to the ones you actually use. - `latest` is a TIMESTAMP, not a date: store it and send it back as `since` next time and the answer is exact — nothing missed, nothing repeated. A bare `YYYY-MM-DD` returns that whole day (deliberately generous: repeating something you already saw is annoying, missing a fix is the problem this exists to prevent). - Atom feed: https://koigrid.com/changelog.xml · Human-readable version: https://koigrid.com/changelog ## Pages - Storage: https://koigrid.com/storage · Share: https://koigrid.com/share · Links & QR: https://koigrid.com/links - Pricing: https://koigrid.com/pricing · Docs: https://koigrid.com/docs