Skip to main content

Documentation

Everything you need to provision, deploy, and claim. Every curl below works as-is.

The whole platform fits in one curl. No signup, no API key, no Docker.

curl -X POST https://api.instanode.dev/db/new \
  -H "Content-Type: application/json" \
  -d '{"name":"prod-db"}'

Every provisioning endpoint requires a name — a human-readable label 1–64 characters long, matching ^[A-Za-z0-9][A-Za-z0-9 _-]*$ (start with a letter or digit; letters, digits, spaces, underscores and hyphens after). Omitting it returns 400 {"error":"name_required"}; an invalid value returns 400 {"error":"invalid_name"}.

The response includes a connection_url you can paste into any Postgres client. The database is real, dedicated, and yours for 24 hours.

When you're ready to keep it, see the Claim flow section below.

Every endpoint returns a connection_url (or endpoint / receive_url / application URL) plus an upgrade_jwt you can hand to /claim.

  • POST /db/new — Postgres (pgvector pre-installed)
  • POST /cache/new — Redis (ACL'd, per-token key prefix)
  • POST /nosql/new — MongoDB
  • POST /queue/new — NATS JetStream
  • POST /storage/new — S3-compatible (DigitalOcean Spaces, nyc3)
  • POST /webhook/new — public URL that receives any HTTP method
  • POST /deploy/new — container deploy (tarball in, HTTPS URL out)

The required name field

Every provisioning endpoint above — plus /stacks/newrequires a name. It is the human-readable label shown in the dashboard and in GET /api/v1/resources.

  • Send name as a JSON string field on /db/new, /cache/new, /nosql/new,
  • /deploy/new and /stacks/new are multipart — pass name as a form field.
  • Validation: 1–64 characters, must match ^[A-Za-z0-9][A-Za-z0-9 _-]*$
  • Omitting name400 {"error":"name_required"}.
  • An invalid value → 400 {"error":"invalid_name"}.
curl -X POST https://api.instanode.dev/db/new \
  -H "Content-Type: application/json" \
  -d '{"name":"prod-db"}'

curl -X POST https://api.instanode.dev/cache/new \ -H "Content-Type: application/json" \ -d '{"name":"sessions-cache"}'

curl -X POST https://api.instanode.dev/nosql/new \ -H "Content-Type: application/json" \ -d '{"name":"events-store"}'

curl -X POST https://api.instanode.dev/queue/new \ -H "Content-Type: application/json" \ -d '{"name":"jobs-queue"}'

curl -X POST https://api.instanode.dev/storage/new \ -H "Content-Type: application/json" \ -d '{"name":"uploads-bucket"}'

curl -X POST https://api.instanode.dev/webhook/new \ -H "Content-Type: application/json" \ -d '{"name":"github-webhook"}' ```

Most responses share the shape { ok, token, connection_url, internal_url, tier, limits, note, upgrade_jwt }. internal_url is the address to use when the caller itself runs inside our cluster (i.e. via /deploy/new) — public hostnames don't hairpin reliably from inside. Two endpoints differ: /webhook/new returns receive_url (no connection_url/internal_url), and /storage/new returns endpoint/prefix/mode (and, in broker mode, a presign_url instead of S3 keys — see Storage isolation below).

NATS queue credentials (2026-05-20+)

The /queue/new response also includes a credentials object with per-tenant NATS account credentials (MR-P0-5):

{
  "ok": true,
  "connection_url": "nats://nats.instanode.dev:4222",
  "subject_prefix": "tenant_a1b2c3d4....",
  "auth_mode": "isolated",
  "credentials": {
    "auth_mode": "isolated",
    "nats_jwt":  "<base64 user JWT>",
    "nats_nkey": "SUAA…",
    "creds_file": "-----BEGIN NATS USER JWT-----\n…",
    "key_id":   "ABBA…"
  }
}

Pass (nats_jwt, nats_nkey) to nats.UserJWTAndSeed() in the NATS Go client, or write creds_file to disk and pass the path to nats.UserCredentials(). When auth_mode is "isolated", the tenant's JWT only permits pub/sub on the subject_prefix.* namespace and cross-tenant publish is denied at the server.

Current production reality (read this). Per-tenant account-JWT isolation is wired end-to-end but is not yet active in production — prod currently issues auth_mode: "legacy_open" for new queues (operator NKey generation is pending). In legacy_open there is no `credentials` block and no server-side cross-tenant enforcement: connect with just the connection_url, and treat the queue as shared-namespace — scope your own subjects under subject_prefix.* at the application layer. The isolated shape above is what you'll receive once isolation is enabled. Resources provisioned before the 2026-05-20 cutover are also legacy_open.

Storage isolation mode (2026-05-20+)

The /storage/new response also includes a mode field that names the isolation level the tenant landed on:

modeMeaning
brokerDO Spaces today — what every new tenant receives. No long-lived credential is issued; the response omits access_key_id/secret_access_key. Use POST /storage/:token/presign for short-lived signed URLs (max 1h TTL).
shared-master-keyLegacy DO Spaces rows only (pre-broker). Every tenant held the master key; isolation was by prefix convention. New tenants do NOT land here.
prefix-scopedBackend IAM enforces s3:prefix against <prefix>/* (R2, S3, MinIO target).
prefix-scoped-temporarySame as prefix-scoped but credentials are STS — they expire.

The mode is decided at boot time by the OBJECT_STORE_BACKEND env var and the backend's Capabilities(). Agents should branch on mode if they need to behave differently — e.g. when broker, never try to write directly with (access_key_id, secret_access_key) since the response won't carry them.

Broker-mode access: POST /storage/{token}/presign

When the /storage/new response carries mode: "broker", no long-lived credential was issued. Use this endpoint to mint a short-lived signed S3 URL (≤1h TTL) constrained to the resource's own prefix/*:

curl -X POST https://api.instanode.dev/storage/$TOKEN/presign \
  -H "Content-Type: application/json" \
  -d '{"operation":"PUT","key":"uploads/photo.jpg","expires_in":600}'
# => {"ok":true,"url":"https://s3.instanode.dev/...?X-Amz-Signature=...","expires_at":"..."}
  • operation"PUT" (upload) or "GET" (download)
  • key — object key, will be prefixed with the resource's <prefix>/
  • expires_in — TTL in seconds, clamped to [1, 3600]; values ≤ 0 are

The URL is signed by the platform master key but enforces the tenant's prefix at sign time, so leaked URLs cannot read or write other tenants' objects. Rate-limited per token.

Operational endpoints

  • GET /healthz — shallow liveness probe. Returns 200 with {ok, commit_id, build_time, version} if the binary is up and can ping its primary platform DB. Use this to verify a deploy SHA matches what you pushed.
  • GET /readyz — deep readiness probe (added 2026-05-20). Multi-component upstream-reachability matrix returning per-check status + latency + last_checked timestamp. Per-check criticality decides 200 vs 503. See Deploying an app for the full envelope shape.
  • POST /webhooks/brevo/:secret — Brevo delivery webhook receiver (internal). Authenticated by URL token. Overwrites forwarder_sent.classification with the real outcome (delivered, bounced_hard, bounced_soft, rejected, complaint, deferred, unsubscribed, error) and stamps delivered_at on delivered. The truth surface for "did the user receive the email" — the worker's 201 from the Brevo API only means the relay accepted the POST; the ledger row's classification (set by this webhook) is the real outcome.

POST /deploy/new takes a multipart form with a gzipped tar archive containing your Dockerfile + source.

curl -X POST https://api.instanode.dev/deploy/new \
  -H "Authorization: Bearer <JWT>" \
  -F "[email protected]" \
  -F "name=expense-tracker" \
  -F "port=8080" \
  -F 'env_vars={"DATABASE_URL":"postgres://..."}'

name is required — it is the human-readable label for the deployment. Send it as a form field. It must be 1–64 characters and match ^[A-Za-z0-9][A-Za-z0-9 _-]*$ (start with a letter or digit; letters, digits, spaces, underscores and hyphens after). Omitting it returns 400 {"error":"name_required"}; an invalid value returns 400 {"error":"invalid_name"}.

The build runs in-cluster on kaniko (~30–90s for typical Node/Python apps) and the app rolls out behind a public HTTPS URL on *.deployment.instanode.dev with a valid Let's Encrypt cert.

env_vars is optional — pass a JSON object and every key/value lands in the app's environment on the first build. Saves you a follow-up PATCH+redeploy.

For multi-service apps see Stacks below.

Deleting a deployment (paid tiers — two-step, email-confirmed)

Paid customers (Hobby, Hobby Plus, Pro, Growth, Team) can free a consumed deployment slot at any time. Because deletion is destructive — every byte of the running app + every env var — the agent CAN initiate but CANNOT finalise destruction. Only the human, by clicking the email link, completes the deletion.

Step 1 — Initiate

curl -X DELETE https://api.instanode.dev/api/v1/deployments/<id> \
  -H "Authorization: Bearer $INSTANODE_TOKEN"

Response (HTTP 202):

{
  "ok": true,
  "id": "<deploy_id>",
  "deletion_status": "pending_confirmation",
  "confirmation_sent_to": "m***@instanode.dev",
  "confirmation_expires_at": "2026-05-14T10:30:00Z",
  "agent_action": "Tell the user to check their email at m***@instanode.dev. The deletion link expires in 15 minutes. To free the slot the user must click the link. The agent CANNOT confirm on the user's behalf — only the human can.",
  "cancellation_note": "Cancel by calling DELETE on the /confirm-deletion path, or let the 15-minute window expire."
}

The agent surfaces agent_action to the user verbatim. The slot stays consumed until confirmation — a fresh POST /deploy/new still hits the per-tier deployments_apps ceiling.

The email link points at the API's /auth/email/confirm-deletion?t=<token>, which 302s the user to the dashboard's /app/confirm-deletion page. The dashboard runs the authenticated POST:

curl -X POST 'https://api.instanode.dev/api/v1/deployments/<id>/confirm-deletion?token=<plaintext>' \
  -H "Authorization: Bearer $INSTANODE_TOKEN"

Response (HTTP 200) → deletion_status: "confirmed", slot is free.

Cancel, expire, agent-override

  • Cancel (user changes their mind): DELETE /api/v1/deployments/<id>/confirm-deletion. Resource stays active.
  • Expire (15 minutes elapsed): the worker flips the row to expired. Re-running DELETE mints a fresh email.
  • Agent override: set X-Skip-Email-Confirmation: yes on the original DELETE → 200 immediate destruction. Use only when the agent has obtained explicit user consent on its own side.

Anonymous tier

Anonymous resources (24h TTL) have no email on file. DELETE returns 200 immediately — no two-step gate, since there is no inbox to mail.

Stacks

Same contract applies to DELETE /api/v1/stacks/<slug>.

Health and readiness probes

The platform exposes two distinct probes on every service (api, worker, provisioner):

  • `GET /healthz` — shallow liveness. Returns 200 with {ok, commit_id, build_time, version} if the binary is up and can ping its primary platform DB. Wired to Kubernetes livenessProbe. Use this to verify a deploy actually rolled out (commit_id should match the git SHA you pushed).
  • `GET /readyz` — deep readiness, added 2026-05-20. A multi-component matrix that walks every upstream the process depends on (platform_db, customer_db, redis-provision, provisioner_grpc, NATS, DO Spaces, Brevo, Razorpay, GeoIP). Per-check criticality decides the HTTP status: platform_db and provisioner_grpc are CRITICAL (a failed check returns 503 and pulls the pod from k8s rotation); everything else degrades to 200 with overall=degraded so a brevo outage degrades email but doesn't blackhole provisioning. Each check runs in parallel behind a 10-15s cache so the readinessProbe cycle doesn't self-DoS upstream rate limits.

Response envelope (same shape across all three services):

{
  "ok": true,
  "overall": "ok",
  "commit_id": "abc1234",
  "checks": {
    "platform_db":      {"status": "ok",       "latency_ms":  4, "last_checked": "2026-05-20T..."},
    "provisioner_grpc": {"status": "ok",       "latency_ms": 12, "last_checked": "2026-05-20T..."},
    "brevo":            {"status": "degraded", "latency_ms": -1, "last_checked": "2026-05-20T...", "message": "brevo upstream timeout"}
  }
}

overall is the worst non-degraded status the criticality matrix permits to be surfaced. New Relic alert readyz_degraded fires on overall != "ok" for 5 consecutive minutes per service.

POST /stacks/new takes an instant.yaml manifest plus one tarball per service. Services can reference each other with service://<name> env values — those resolve to cluster-internal http://<name>:<port> URLs at deploy time.

The multipart form requires a name field — the human-readable label for the stack. It must be 1–64 characters and match ^[A-Za-z0-9][A-Za-z0-9 _-]*$ (start with a letter or digit; letters, digits, spaces, underscores and hyphens after). Omitting it returns 400 {"error":"name_required"}; an invalid value returns 400 {"error":"invalid_name"}.

curl -X POST https://api.instanode.dev/stacks/new \
  -H "Authorization: Bearer <JWT>" \
  -F "name=shop-stack" \
  -F "[email protected]" \
  -F "[email protected]" \
  -F "[email protected]"
services:
  api:
    build: ./api
    port: 3000
  web:
    build: ./web
    port: 8080
    expose: true
    env:
      API_URL: service://api

Only services with expose: true get a public URL — the rest are in-cluster only. The whole stack rolls out together; partial failure is reported per-service in GET /stacks/{slug}.

Anonymous resources expire in 24 hours. To keep them, claim them.

RESP=$(curl -X POST https://api.instanode.dev/db/new \
  -H "Content-Type: application/json" \
  -d '{"name":"prod-db"}')
JWT=$(echo $RESP | jq -r .upgrade_jwt)

Optional preview — shows what would attach, no side effects curl "https://api.instanode.dev/claim/preview?t=$JWT"

Trigger the claim — sends a magic link to your email curl -X POST https://api.instanode.dev/claim \ -d "{\"jwt\":\"$JWT\", \"email\":\"[email protected]\"}" ```

Click the magic link to set a session cookie. Every resource attached to your fingerprint transfers to your team atomically; the connection URLs don't change so any already-running code keeps working.

Claimed resources move to your team's tier (hobby by default — $9/mo). There is no separate trial period on paid tiers — the 24-hour anonymous slice is the trial.

Resource provisioning is anonymous. Everything else (deploy, vault, billing, team management) requires a session JWT.

How to get one:

  1. Provision any resource anonymously. The response includes a JWT in the
  2. POST that JWT to /claim with an email. We send a magic link.
  3. Click the link in the email; the page sets a session cookie.

For unattended use (CI, agents), exchange the session cookie for a long-lived API key at POST /api/v1/auth/api-keys. Pass it as Authorization: Bearer <key> on every request.

To verify a token works at any time, hit GET /api/v1/whoami — returns 200 with your team_id + plan_tier on success, 401 on failure.

TierPostgresRedisMongoDBTTLPrice
Anonymous10MB / 2c5MB5MB / 2c24hfree
Hobby1GB / 8c50MB100MB / 5cnone$9 / mo
Pro10GB / 20c512MB5GB / 20cnone$49 / mo
Teamunlimitedunlimitedunlimitednone$199 / mo

"c" = simultaneous connections. The full table is at /pricing.

Hobby Plus and Growth exist in plans.yaml as upsell-only intermediate tiers reached via in-dashboard prompts when a Hobby user hits a quota wall. They are deliberately omitted from the public tier ladder to keep the customer-facing comparison simple.

Team tier status: Team is live and self-serve at $199/mo (the API no longer returns tier_unavailable for plan=team). Note: self-serve checkout for ALL paid tiers (Hobby/Pro/Team) currently depends on the Razorpay recurring-billing rollout — until that operator step completes, POST /api/v1/billing/checkout may return a 502/503; contact [email protected] for assisted onboarding in the meantime.

Limits are enforced at the Postgres user level (CONNECTION LIMIT on the role) and via per-bucket storage quotas. Exceeding a limit returns a 402 with an upgrade URL — your app keeps running, the next provision just fails.

POST /api/v1/billing/checkout — concurrent-call dedup

POST /api/v1/billing/checkout is server-side deduplicated per team. A second concurrent call for the same team — within a 60s window — gets a structured 409 instead of a second Razorpay subscription. This catches cross-tab clicks, mobile double-taps, retried form submits, and agents that retry the endpoint without coordination.

Response envelope:

{
  "ok": false,
  "error": "checkout_in_flight",
  "message": "A checkout is already being created for this team. Wait ~60s and retry, or visit /dashboard to find the existing pending subscription.",
  "retry_after_seconds": 60,
  "agent_action": "Tell the user a checkout is already being created. They should wait ~60 seconds and refresh — the existing checkout link will appear in the dashboard.",
  "request_id": "..."
}

The retry_after_seconds field tells callers how long to wait. The TTL also caps the worst case where the first caller crashes mid-flight — after 60s a retry is allowed automatically. The standard Idempotency-Key header (see /docs/idempotency) is honoured on this route too and provides a longer-window guarantee — pass it on every retry of a logical checkout attempt.

If Redis is unavailable the dedup guard fails open (the call proceeds), with a WARN billing.checkout.dedup_setnx_failed_open log line. A Redis brownout must never block a paid upgrade — the idempotency middleware is the second layer of defence.

The full API surface is described in OpenAPI 3.1 at:

https://api.instanode.dev/openapi.json

It is the source of truth for paths, schemas, security schemes, and which endpoints accept anonymous traffic. Agents reading this spec alone can discover the claim flow (described under securitySchemes.bearerAuth), the /api/v1/whoami identity probe, and which fields like upgrade_jwt to pass forward.

If you're an AI agent reading this, the recommended bootstrap is:

  1. GET /openapi.json
  2. Provision anonymous resources
  3. GET /api/v1/whoami to confirm token validity once you have one