Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions docs/limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ order: 7
| 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 | coming soon |
| Team | unlimited | unlimited | unlimited | none | $199 / mo |

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

Expand All @@ -17,11 +17,12 @@ 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:** the tier is defined in `plans.yaml` with the limits
above, but customer-initiated checkout for it is blocked at the API level
(`POST /api/v1/billing/checkout` returns `400 tier_unavailable` for
`plan=team`). Email support@instanode.dev for early access. Pro is the
ceiling for self-serve upgrades today.
**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 support@instanode.dev 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
Expand Down
31 changes: 20 additions & 11 deletions docs/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,13 @@ curl -X POST https://api.instanode.dev/webhook/new \
-d '{"name":"github-webhook"}'
```

Every response has the same shape: `{ ok, token, connection_url, internal_url,
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.
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+)

Expand All @@ -82,12 +85,18 @@ NATS account credentials (MR-P0-5):

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()`.
The tenant's JWT only permits pub/sub on the `subject_prefix.*` namespace —
publish to other tenants' subjects is denied at the server.

Resources provisioned before the operator-mode cutover (2026-05-20) carry
`auth_mode: "legacy_open"` and have no `credentials` field; they keep working
on the unauthenticated path until they recycle.
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+)

Expand All @@ -96,10 +105,10 @@ isolation level the tenant landed on:

| mode | Meaning |
|---|---|
| `shared-master-key` | DO Spaces today. Every tenant holds the master key; isolation is by `prefix` convention. |
| `prefix-scoped` | Backend IAM enforces `s3:prefix` against `<prefix>/*` (R2, S3, MinIO). |
| `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. |
| `broker` | No long-lived credential is issued. Use `POST /storage/:token/presign` for short-lived signed URLs (max 1h TTL). |

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
Expand Down
8 changes: 4 additions & 4 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ On a prompt like that, the agent should: `POST /db/new` for Postgres, write the

## API endpoints

All accept `POST` against `https://api.instanode.dev`. No authentication header required for the anonymous tier, but every request body **must** include `name` (see below). Response shape: `{ok, token, connection_url, internal_url, tier, limits, note, upgrade_jwt, upgrade}` for db/cache/nosql/queue — where `upgrade` is the human-readable claim link (e.g. `https://api.instanode.dev/start?t=<jwt>`) and `upgrade_jwt` is the raw JWT for programmatic use. `/webhook/new` returns `receive_url` (no internal_url). `/storage/new` adds `endpoint`, `access_key_id`, `secret_access_key`, `prefix` alongside `connection_url`.
All accept `POST` against `https://api.instanode.dev`. No authentication header required for the anonymous tier, but every request body **must** include `name` (see below). Response shape: `{ok, token, connection_url, internal_url, tier, limits, note, upgrade_jwt, upgrade}` for db/cache/nosql/queue — where `upgrade` is the human-readable claim link (e.g. `https://api.instanode.dev/start?t=<jwt>`) and `upgrade_jwt` is the raw JWT for programmatic use. `/webhook/new` returns `receive_url` (no internal_url). `/storage/new` returns `endpoint`, `prefix`, and a `mode` field alongside `connection_url`; in the default `broker` mode (DO Spaces today, all tiers) it OMITS `access_key_id`/`secret_access_key` and instead returns `presign_url` + `agent_action:"use_presign_endpoint"` — call `POST /storage/:token/presign` for short-lived signed URLs (see the storage section below).

### Required `name` field (all provisioning endpoints)

Expand All @@ -33,7 +33,7 @@ Pick a descriptive name per resource (e.g. `"prod-db"`, `"sessions-cache"`, `"ev
- **`POST /cache/new`** — Redis. Requires `name`. Per-token ACL'd user + namespaced keyspace. Returns `connection_url` in the form `redis://:PASS@HOST:PORT/DB`.
- **`POST /nosql/new`** — MongoDB. Requires `name`. Per-token user scoped to a single database. Returns a `mongodb://...` connection URL. The per-token connection budget is documented in the response `limits.connections` field (e.g. anonymous = 2). The underlying shared-tenant pod admits up to 20 simultaneous connections across all tokens, so plan agents to stay well below their per-token allocation under burst.
- **`POST /queue/new`** — NATS JetStream. Requires `name`. Returns `connection_url` (`nats://host:4222`) plus a `credentials` object with per-tenant NATS account creds: `credentials.nats_jwt`, `credentials.nats_nkey`, and a pre-rendered `credentials.creds_file` blob. Pass `(nats_jwt, nats_nkey)` to `nats.UserJWTAndSeed()` or write `creds_file` to disk and use `nats.UserCredentials(path)`. Each tenant gets its own NATS account — JetStream streams, subjects, and pub/sub are isolated at the server. `subject_prefix` in the response names the subject namespace this resource is scoped to. The response also includes `auth_mode` ("isolated" or "legacy_open" for grandfathered pre-cutover rows). Durable streams, request/reply, pub/sub.
- **`POST /storage/new`** — S3-compatible bucket prefix backed by DigitalOcean Spaces (`nyc3`). Requires `name`. Returns `connection_url` (`https://s3.instanode.dev/instant-shared/<prefix>/`) plus `endpoint`, `access_key_id`, `secret_access_key`, `prefix`, and a `mode` field that names the isolation level the tenant landed on: `shared-master-key` (DO Spaces today — every tenant holds the master key, prefix-by-convention), `prefix-scoped` (backend IAM enforces `s3:prefix` against `<prefix>/*` — R2/S3/MinIO), `prefix-scoped-temporary` (same but credentials expire — STS), or `broker` (no long-lived credential — call `POST /storage/:token/presign` for short-lived signed URLs, max 1h TTL). Configure an S3 client with `endpoint`, `access_key_id`, `secret_access_key`, bucket=`instant-shared`, and constrain object keys to start with `prefix`. Anonymous-tier objects are auto-deleted at 24h by a bucket lifecycle rule enforced at the storage layer. See [/use-cases/screenshot-evidence-archive.md](https://instanode.dev/use-cases/screenshot-evidence-archive.md) for a worked example.
- **`POST /storage/new`** — S3-compatible bucket prefix backed by DigitalOcean Spaces (`nyc3`). Requires `name`. Returns `connection_url` (`https://s3.instanode.dev/instant-shared/<prefix>/`) plus `endpoint`, `prefix`, and a `mode` field that names the isolation level the tenant landed on. **Today, on DO Spaces, every new tenant (all tiers) lands in `broker` mode**: NO long-lived credential is returned — the response OMITS `access_key_id`/`secret_access_key` and instead carries `presign_url` + `agent_action:"use_presign_endpoint"`. Call `POST /storage/:token/presign` for short-lived (≤1h) signed S3 URLs scoped to your `prefix/*`. The other modes are not currently issued to new tenants: `shared-master-key` (legacy DO Spaces rows only — every tenant held the master key, prefix-by-convention), `prefix-scoped` (backend IAM enforces `s3:prefix` against `<prefix>/*` — R2/S3/MinIO target), `prefix-scoped-temporary` (same but credentials expire — STS). Anonymous-tier objects are auto-deleted at 24h by a bucket lifecycle rule. See [/use-cases/screenshot-evidence-archive.md](https://instanode.dev/use-cases/screenshot-evidence-archive.md) for a worked example.
- **`POST /webhook/new`** — Public receive URL that captures any HTTP method. Requires `name`. Returns `receive_url`. Inspect received payloads at `GET https://api.instanode.dev/api/v1/webhooks/{token}/requests`.
- **`POST /storage/{token}/presign`** — Mint a short-lived (≤1h) signed S3 URL for a storage resource that landed in `mode="broker"` (no long-lived credential issued by `/storage/new` — DO Spaces today for new tenants). Body: `{"operation": "PUT"|"GET", "key": "<object-key-relative-to-prefix>", "expires_in": <seconds, 1..3600>}`. Returns `{ok, url, expires_at}`. Signed by the platform master key but constrained to the resource's own `prefix/*`, so a leaked URL cannot escape the tenant boundary. Rate-limited per token. Don't use this when the `/storage/new` response carried `(access_key_id, secret_access_key)` — go direct to S3 in that case.
- **`POST /webhooks/brevo/:secret`** — Brevo delivery webhook receiver (internal — Brevo's transactional pipeline POSTs here for every delivery event). Authentication is by URL token: the `{secret}` path segment is constant-time-compared against the platform's `BREVO_WEBHOOK_SECRET`. Handled events: `delivered`, `soft_bounce`, `hard_bounce`, `blocked`, `complaint`, `deferred`, `unsubscribed`, `error`. The handler overwrites the matching `forwarder_sent` row's `classification` with the real Brevo outcome and stamps `delivered_at` on `delivered` only. Unknown messageIds return `200 {"matched":false}` (Brevo retries on 5xx — orphan events must not amplify retries). Unhandled event types (`click`, `open`, `request`) return `200 {"skipped":true}`. This is the truth surface for "did the user receive the email" — the worker's 201 from Brevo's API only means the relay queued the message; `forwarder_sent.classification` (set by this webhook) is the actual delivery outcome.
Expand Down Expand Up @@ -94,7 +94,7 @@ via dashboard prompts when a Hobby user hits a wall) and are deliberately omitte
the public tier ladder to keep the customer-facing comparison simple. Both still surface
on `/api/v1/capabilities` for agent introspection.

- **Anonymous**: free, 24h TTL, the limits above.
- **Anonymous**: free, 24h TTL, the limits above. Note: once an anonymous resource expires, the same network fingerprint must do a one-time email claim before provisioning a replacement — a repeat `POST /<service>/new` returns `402 {"error":"free_tier_recycle_requires_claim", "claim_url": "..."}` with an `agent_action`. Tell the user to claim at the `claim_url` (30 seconds, no card), then retry.
- **Hobby**: $9/mo. 1 GB Postgres, 50 MB Redis, 100 MB Mongo, 512 MB storage, 1 app deploy. No TTL.

> **Upgrading auto-promotes in-flight deployment TTLs.** When a team upgrades to any paid tier (Hobby / Hobby Plus / Pro / Growth / Team), the Razorpay subscription.charged webhook flips the team's `default_deployment_ttl_policy` from `auto_24h` to `permanent` (so every future `POST /deploy/new` defaults to no TTL) AND promotes every existing `auto_24h` non-terminal deploy to permanent (clearing `expires_at`). Per-deploy `ttl_policy='custom'` and `ttl_policy='permanent'` rows are never touched — only the `auto_24h` class is rolled forward. To restore the 24h-default behaviour after an upgrade, `PATCH /api/v1/team/settings {"default_deployment_ttl_policy":"auto_24h"}`.
Expand All @@ -105,7 +105,7 @@ on `/api/v1/capabilities` for agent introspection.

0. **`name` is required on every provision** — `/db/new`, `/cache/new`, `/nosql/new`, `/queue/new`, `/storage/new`, `/webhook/new`, `/deploy/new`, `/stacks/new` all reject a request with no `name`. Send a JSON string field (`-d '{"name":"prod-db"}'`) — or a form field on `/deploy/new` and `/stacks/new` (`-F "name=my-app"`). Rules: 1–64 chars, `^[A-Za-z0-9][A-Za-z0-9 _-]*$`. Missing → `400 {"error":"name_required"}`; malformed → `400 {"error":"invalid_name"}`.
1. **One curl per service** — no batch endpoints, no /provision/all. Run them in parallel from bash with `&` or async in code.
2. **Parse the URL field directly** — every response has `.connection_url` except `/webhook/new` (`.receive_url`) and `/storage/new` (`.connection_url` plus `.endpoint`/`.access_key_id`/`.secret_access_key` for SDK use). Never construct URLs by hand.
2. **Parse the URL field directly** — every response has `.connection_url` except `/webhook/new` (`.receive_url`) and `/storage/new` (`.connection_url` plus `.endpoint`/`.prefix`/`.mode`; in `broker` mode — DO Spaces today — no `access_key_id`/`secret_access_key` is returned, use the `.presign_url` / `POST /storage/:token/presign` flow instead). Never construct URLs by hand.
3. **Save `upgrade_jwt`** if the user wants to claim the resource later. It's also required as a `Bearer` token for `POST /deploy/new`.
4. **No retry-on-201** — provisioning is synchronous. If the call returns 200/201, the resource is ready to accept connections.
5. **Internal vs public URL** — when the calling code itself runs *inside* a `/deploy/new` container, prefer `internal_url` (cluster-internal hostname). For everything else, use `connection_url` (public).
Expand Down
2 changes: 1 addition & 1 deletion pages/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,5 @@ For a chronological list of incidents, see the HTML page. Recent fixes and platf
## Machine-readable

- Status JSON: `https://api.instanode.dev/api/v1/status`
- Per-service metrics (Prometheus format): `https://api.instanode.dev/metrics`
- Per-service metrics (Prometheus format): `https://api.instanode.dev/metrics` (requires a bearer token — not public; returns `401` without it)
- OpenAPI spec: `https://api.instanode.dev/openapi.json`
Loading