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— MongoDBPOST /queue/new— NATS JetStreamPOST /storage/new— S3-compatible (DigitalOcean Spaces,nyc3)POST /webhook/new— public URL that receives any HTTP methodPOST /deploy/new— container deploy (tarball in, HTTPS URL out)
The required name field
Every provisioning endpoint above — plus /stacks/new —
requires a name. It is the human-readable label shown in the dashboard
and in GET /api/v1/resources.
- Send
nameas a JSON string field on/db/new,/cache/new,/nosql/new, /deploy/newand/stacks/neware multipart — passnameas a form field.- Validation: 1–64 characters, must match
^[A-Za-z0-9][A-Za-z0-9 _-]*$ - Omitting
name→400 {"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 issuesauth_mode: "legacy_open"for new queues (operator NKey generation is pending). Inlegacy_openthere is no `credentials` block and no server-side cross-tenant enforcement: connect with just theconnection_url, and treat the queue as shared-namespace — scope your own subjects undersubject_prefix.*at the application layer. Theisolatedshape above is what you'll receive once isolation is enabled. Resources provisioned before the 2026-05-20 cutover are alsolegacy_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:
| mode | Meaning |
|---|---|
broker | DO 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-key | Legacy DO Spaces rows only (pre-broker). Every tenant held the master key; isolation was by prefix convention. New tenants do NOT land here. |
prefix-scoped | Backend IAM enforces s3:prefix against <prefix>/* (R2, S3, MinIO target). |
prefix-scoped-temporary | Same 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. Overwritesforwarder_sent.classificationwith the real outcome (delivered,bounced_hard,bounced_soft,rejected,complaint,deferred,unsubscribed,error) and stampsdelivered_atondelivered. 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.
Step 2 — User clicks the email link
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-runningDELETEmints a fresh email. - Agent override: set
X-Skip-Email-Confirmation: yeson the originalDELETE→ 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 KuberneteslivenessProbe. Use this to verify a deploy actually rolled out (commit_idshould 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_dbandprovisioner_grpcare CRITICAL (a failed check returns 503 and pulls the pod from k8s rotation); everything else degrades to 200 withoverall=degradedso a brevo outage degrades email but doesn't blackhole provisioning. Each check runs in parallel behind a 10-15s cache so thereadinessProbecycle 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://apiOnly 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:
- Provision any resource anonymously. The response includes a JWT in the
- POST that JWT to /claim with an email. We send a magic link.
- 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.
| Tier | Postgres | Redis | MongoDB | TTL | Price |
|---|---|---|---|---|---|
| Anonymous | 10MB / 2c | 5MB | 5MB / 2c | 24h | free |
| Hobby | 1GB / 8c | 50MB | 100MB / 5c | none | $9 / mo |
| Pro | 10GB / 20c | 512MB | 5GB / 20c | none | $49 / mo |
| Team | unlimited | unlimited | unlimited | none | $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.jsonIt 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:
GET /openapi.json- Provision anonymous resources
GET /api/v1/whoamito confirm token validity once you have one