Stacktree API documentation
The publish primitive for agent-made HTML.
private by default MCP-native replace-in-place
Handing this to a coding agent? Give it the OpenAPI spec and go: api.stacktr.ee/openapi.json for the HTTP API, or agents.stacktr.ee/openapi.json for the pay-per-publish front-door. MCP server: https://api.stacktr.ee/mcp. Machine-readable index: stacktr.ee/llms.txt.
For a guided walkthrough with your actual API key inlined into snippets, open app.stacktr.ee/connect.
Connect an agent
Hand it to the agent
If you have an agent open, give it this and it does the rest — installs, verifies, and learns the tool surface:
Fetch and follow the setup instructions at https://stacktr.ee/prompt.md
Works in any agent that can fetch a URL. The instructions are plain Markdown at stacktr.ee/prompt.md — read them before you run them if you like.
npx stacktree-install — recommended
One command wires every agent at once — Claude Code, Cursor, Codex, OpenCode, Amp — and drops the stacktree-publish skill in for Claude:
npx stacktree-install
It signs you in via a one-time code at app.stacktr.ee/connect/cli and mints an API key automatically; pass an existing key as an argument (npx stacktree-install stk_live_…) to skip sign-in.
Claude.ai — custom connector
No CLI, no API key copy-paste.
- Open claude.ai/settings/connectors → Add custom connector.
- Paste
https://api.stacktr.ee/mcpas the Remote MCP server URL. - Leave OAuth Client ID/Secret blank — Stacktree auto-registers via Dynamic Client Registration (RFC 7591).
- Click Add → Claude.ai redirects you to Stacktree to sign in and approve. Tools are then available in any conversation.
Claude Code · Codex — CLI
One-line install. Identical syntax between the two:
claude mcp add stacktree -- npx -y stacktree-mcp
codex mcp add stacktree -- npx -y stacktree-mcp
Both expose the same 25 tools (see MCP server). Set STACKTREE_API_KEY in your shell — generate one at app.stacktr.ee/api-keys.
Prefer a skill? Installs SKILL.md + helper script into your agent's skills directory:
npx skills@latest add stevysmith/stacktree-skill
export STACKTREE_API_KEY=stk_live_...
Source: github.com/stevysmith/stacktree-skill · the whole collection at stacktr.ee/skills
MCP config file — Cursor / Claude Desktop / Windsurf / Zed
{
"mcpServers": {
"stacktree": {
"command": "npx",
"args": ["-y", "stacktree-mcp"],
"env": { "STACKTREE_API_KEY": "stk_live_..." }
}
}
}
Drop into ~/.cursor/mcp.json, ~/Library/Application Support/Claude/claude_desktop_config.json, ~/.codeium/windsurf/mcp_config.json, or the context_servers key in Zed's settings.
Slack
Add the Slack app (one approval; installing mints the workspace its own free identity, no Stacktree account needed). Then ⋮ → Host on Stacktree on any message with an .html or .md file — channels or DMs — posts a private link back into the conversation. Re-uploading the same filename republishes to the same URL. /stacktree link migrates the workspace's sites into a dashboard account. Details: stacktr.ee/slack.
HTTP API
Auth
Three methods, all resolve to the same user context:
Create at app.stacktr.ee/api-keys — or let an agent buy its own over x402 or MPP.
Clerk session token, for dashboard-originated calls.
Access token from /oauth/token, used by custom connectors.
/sitesUpload a single HTML/markdown file or a zip. multipart/form-data. Anonymous uploads work — no auth header — and live 24 hours.
.html / .htm / .md / .zip
Opt-in public subdomain; authed only.
Passcode gate on serve. Works on every plan, including all 3 free-plan pages and anonymous publishes.
Default: 24h anonymous, no expiry on a paid plan. Free is capped at 7 days. A number over the ceiling is clamped to it and the response says so (expiry_clamped: true). "never" on a capped plan is refused, not silently shortened: 409 expiry_clamped, nothing published, and the body carries would_expire_at_iso. That is deliberate, because a 201 reads as success and the permanence gets repeated to a person before anyone checks a flag. Send accept_clamp=true to take the ceiling. Omit the field entirely (or send it empty) and the plan's own default applies, same over MCP as over the raw API. A value that is not a number of hours or the word "never" — "7d", -5, "soon" — is 400 invalid_expiry and nothing is published: it used to be read as "no deadline requested", which on a paid plan meant a permanent page the caller had not asked for.
Only needed alongside expires_in_hours=never on a plan that caps page lifetime: it says the ceiling is acceptable and publishes.
Makes a retry safe. Any unique string, 1 to 255 visible ASCII characters. The same key with the same body inside 24 hours returns the original page (same id, same URL, same tokens) with Idempotent-Replay: true, and spends no second page against the Free lifetime cap. A different body under the same key is 422 idempotency_key_reused, never the old page. Two requests carrying one key cannot both publish: the loser gets 409 idempotency_key_in_progress and should retry. Keys are scoped per caller. On an anonymous publish there is no account to scope to and the scope is the network address, so the key itself must be unguessable (a UUID): a short one is 400 idempotency_key_too_weak, because two agents behind one office IP both keying a shared template "1" would otherwise hand the second one the first one's page and its claim_token. If the page a key made has since been deleted or burned, the retry is 409 idempotent_page_gone rather than a 201 for a dead link.
Delete after first view.
Inject feedback toolbar on serve.
Disable strict CSP (default on). The strict policy allows Google Fonts and Loom / YouTube / Vimeo / Wistia / Descript / Calendly embeds, and blocks remote scripts and remote images. A publish that contains something the policy would block still succeeds and returns a warnings array saying what will not render.
Treat upload as ciphertext; the decryption key lives in the URL fragment and is never sent to Stacktree.
Default warn (MCP layer overrides to block).
curl -F file=@page.html \
-F password=hunter2 \
-F expires_in_hours=72 \
-H "Authorization: Bearer stk_live_..." \
https://api.stacktr.ee/sites
{
"id": "…",
"url": "https://stacktr.ee/p/abc123…/",
"visibility": "unlisted",
"expires_at": 1781234567,
"expires_at_iso": "2026-06-08T12:02:47Z",
"ttl_seconds": 604800,
"expiry_clamped": false,
"expiry_ceiling_hours": 168,
"expiry_source": "plan_ceiling",
"file_count": 1,
"size_bytes": 1234,
"has_password": true,
"agentation": false
}
/sites/:idOrSlugReplace a site's files in place — the URL never changes, and no second page is created. Two body shapes: application/json with an html string, which is what the paid rails already speak, or multipart/form-data with the same fields as POST (the only shape that carries a zip, a PDF or e2e ciphertext). Three credentials, one per request: an account key or OAuth token; the page's own claim_token while it is unclaimed (Authorization: Claim <claim_token>, see below); or, on a wallet-paid page, a signed wallet challenge. Anything else is a 401. E2E-encrypted sites must be replaced with e2e=true multipart uploads (no silent downgrade to plaintext).
curl -X PUT https://api.stacktr.ee/sites/my-deck \
-H "Authorization: Bearer stk_live_..." \
-H "Content-Type: application/json" \
-d '{"html":"<!doctype html><h1>v2</h1>"}'
The optional fields are the same either way: expected_updated_at (refuse the write if the page changed underneath you), pii_check, and on multipart e2e. On JSON, filename replaces a page published as a machine asset at its own path instead of relocating it to index.html.
/sites/:idOrSlugUpdate settings without re-uploading files. JSON body.
Set or remove the passcode gate. Works on every plan; removing one always works too.
null (or the string "never") cancels expiry on a paid plan. On a capped plan it is refused (409 expiry_clamped, nothing in the PATCH applied) rather than quietly becoming 7 days; add accept_clamp: true to take the ceiling. A number over the ceiling is clamped, with expiry_clamped: true in the response. Numbers may be sent as strings ("24") and mean the same thing here as on publish; anything neither readable as hours nor "never" is 400 invalid_expiry and nothing is changed.
Viewers verify an email on that domain before the page renders. Paid plans only; Free gets 402 plan_viewer_gate_not_available.
Claim or release a public subdomain.
Toggle the serve-time behaviors documented under POST.
curl -X PATCH \
-H "Authorization: Bearer stk_live_..." \
-H "Content-Type: application/json" \
-d '{"agentation": true, "expires_in_hours": null}' \
https://api.stacktr.ee/sites/my-deck
List · fetch · raw · delete · restore
List your sites. Pages that expired or were deleted stay in the list rather than disappearing: they carry deleted_at, delete_reason and restorable_until. Check deleted_at before handing anyone a url: a row that has it set is a dead link.
One site, with file manifest + absolute preview_url.
Page HTML stripped of head/scripts — clean text for re-feeding into an agent. Honors password gates.
Takes the page down now: the link is dead for everyone holding it and the plan slot is freed immediately. The content is kept for 30 days, and restorable_until in the response is the deadline after which it is destroyed for good. On Free this does not give a lifetime page slot back, because that cap counts publishes, not live pages. Deleting a page that is already down returns already_deleted: true rather than an error.
Puts a deleted or expired page back at the same URL, with the same id, token, slug and read history, so links already sent start working again. This, not another publish, is the answer to a 409 site_deleted: republishing mints a different URL and spends another lifetime page, a restore spends none. A rescue rather than a renewal, so read expires_at_iso and restored_for off the response: a page that ran out of time on a plan with an expiry ceiling comes back for 48 hours (restored_for: "grace", and restore_grace_hours states the number), not a fresh full lifetime. An owner-deleted page keeps the deadline it already had ("kept"), including no deadline at all. 404 once the 30 days are up, and for a takedown, which is never restorable.
Viewer numbers are plan-gated. GET /sites and GET /sites/:idOrSlug return metrics_locked: true, with view_count, unique_viewers and last_viewed_at as null, when the plan has no view metrics (anonymous and Free). A boolean opened comes through on every plan, so you can tell someone read the page without seeing how many did. The redaction is server-side, so an agent reading this JSON sees exactly what the dashboard does.
Share tokens
POST https://api.stacktr.ee/sites/:idOrSlug/share-tokens { "label": "alice", "max_uses": 5, "expires_in_hours": 168 }
GET https://api.stacktr.ee/sites/:idOrSlug/share-tokens
DELETE https://api.stacktr.ee/share-tokens/:tokenId
Returns a URL with ?t=… appended. Bypasses the password gate when valid; revocable per-token; optional max-use counter and expiry.
Feedback
GET https://api.stacktr.ee/sites/:idOrSlug/feedback
POST https://api.stacktr.ee/feedback/:id/resolve { "note": "fixed the header spacing" }
DELETE https://api.stacktr.ee/feedback/:id
Viewer annotations left via the on-page Agentation toolbar (enable with agentation on upload, PATCH, or set_agentation). Each item carries the comment plus the annotated element, selected text, intent, and severity. Unresolved first. The loop: read feedback → fix the page with update_site (same URL) → resolve.
Reactions & engagement
GET https://api.stacktr.ee/sites/:idOrSlug/reactions { counts, total, reactors, messages }
GET https://api.stacktr.ee/sites/:idOrSlug/engagement { sessions, median_active_seconds, avg_scroll, read_to_end_pct, buckets }
How a page landed, read by the owner. Reactions: turn on the on-page reaction bar per site (dashboard Settings) and viewers react with an emoji or leave a short private note, no account. Engagement (Studio and up): aggregate, non-PII read metrics: typical time on page, scroll depth, read-to-end rate, and a 10-bucket attention heatmap of dwell by page depth. No recording, no session replay. Both feed the dashboard activity bell and the optional daily email digest.
API keys
POST https://api.stacktr.ee/api-keys { "label": "claude desktop" }
GET https://api.stacktr.ee/api-keys
DELETE https://api.stacktr.ee/api-keys/:id
Device code — get a key onto an agent with no browser
OAuth 2.0 Device Authorization Grant (RFC 8628). This is what npx stacktree-install runs, and it is callable directly by any agent: the agent prints a URL and a short code, a human approves it on their own device, and the agent polls until a key comes back. Use it when the account already belongs to a person — an agent that has no human to ask should buy its own key over x402 at POST https://api.stacktr.ee/provision instead.
POST https://api.stacktr.ee/api-keys/device-code { "client_hint": "my-agent" }
→ { device_code, user_code, verification_url, verification_url_complete, interval, expires_in }
# print verification_url_complete for the human, then poll every "interval" seconds:
POST https://api.stacktr.ee/api-keys/device-code/poll { "device_code": "…" }
→ { "status": "pending" } keep polling
→ { "status": "authorized", "api_key": "stk_live_…" } store it; shown once
→ { "status": "denied" | "expired" } stop
No authentication on either call. Codes live 10 minutes, the human's approval mints the key against their account, and the key is handed over exactly once — a poll that misses it means re-authorizing. Rate limited per IP. The resulting stk_live_ key works on the REST API and on MCP alike.
MCP server
Streamable HTTP MCP server at https://api.stacktr.ee/mcp (spec 2025-11-25), exposing 25 tools. Two credentials, both accepted on the same endpoint:
Authorization: Bearer stk_live_…— an API key. No browser, no OAuth dance, nothing to register: the same key that drives the REST API drives MCP. This is the path for an unattended agent, and for any client that can only send a static header. An agent with no key at all can buy one over x402 atPOST https://api.stacktr.ee/provision, or attach itself to a human's existing account with the device-code flow below.- OAuth 2.1 + Dynamic Client Registration — for connectors acting on behalf of a signed-in human (claude.ai, Cursor, and friends). Nothing to pre-register; see OAuth.
Session cookies are deliberately NOT accepted here, so a browser cannot be made to drive MCP cross-site. Send exactly one credential.
curl -X POST https://api.stacktr.ee/mcp \
-H "Authorization: Bearer stk_live_..." \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
Tools (25)
Turn HTML into a link a person can open in a browser.
Replace the HTML of an existing site in place. The URL stays the same, so everyone you already sent it to sees the new version without being sent anything.
Take a page down. The link goes dead immediately for everyone holding it, and the content is kept for 30 days: restore_site puts it back at the same URL, with the same id, token, slug and read history, any time in that window.
Put a page back at the same URL after it was deleted or ran out of time.
Adopt a page that was published without an account into the account this connection is authenticated as.
Set or clear (null) a viewer passcode on a site. Works on every plan (free covers its 3 pages).
Set hours-from-now expiry, or null for never. A number longer than the plan allows is shortened to the ceiling and the response says so (expiry_clamped: true).
Mint a share link addressed to one person. Put their name in `label` — every open through this link comes back attributed to that name, visible on the page in the dashboard and in the read-receipt email.
The share links on a page, with how many attributed opens each has had and when it was last opened. `opens` counts human page opens through that link; `use_count` is the raw enforcement counter for max_uses and is not a read metric.
Kill one share link. The page and every other link keep working — this is how you cut off one recipient without re-issuing anything to the others.
Toggle the on-page Agentation feedback toolbar. When on, viewers can annotate the page and their comments are collected — read them with list_feedback, fix the page with update_site, then resolve_feedback.
Restrict viewer access to a specific email domain. Paid plans only: on a free plan this returns HTTP 402 plan_viewer_gate_not_available.
List sites owned by the authenticated user, newest-updated first.
Who this connection is publishing as, and what its plan actually allows.
List the client spaces on this account: slug, name, page_count, last activity, hostname (the space’s own address, e.g. acme.theiragency.com, when one is connected) and portal_enabled (whether the space serves a generated client portal at that address).
File an existing site under a client space (by name or slug, auto-created), or pass client: null to detach it to a floating page.
Create a client space before anything is published into it. Rarely needed: publish_html with a client argument auto-creates the space under the same casing and slug rules, so use this only when the user is setting a client up ahead of the work.
Rename a client space, archive or unarchive it, or set the viewer gate that covers every page in the space.
Delete a client space. The pages filed under it are NOT deleted: they detach to floating pages and keep working on their existing URLs, so delivered work stays reachable.
Fetch the Stacktree house design guide for improving a published page.
Read the current HTML source of a site you own, so you can edit it and call update_site to change it in place.
Read a page’s content back. format "html" (the default) returns the exact stored index.html, byte for byte, which is the only form you can edit and hand to update_site; format "text" returns the same page stripped to plain text — no markup, CSS, scripts or…
Read viewer feedback left on a site via the Agentation toolbar (enable it with set_agentation).
Mark a feedback item as addressed after fixing the page. Pass the feedback item id from list_feedback, and optionally a short note describing what you changed.
Link your wallet to a Stacktree account so the pages you publish are owned there — and adopt the ones you already published.
Privacy-first MCP defaults
Agents act autonomously without a human reviewing every flag. The MCP layer applies tighter defaults than the raw API:
- Plan-aware expiry. Omitting
expires_in_hourstakes the plan default — 24h anonymous, 7 days on Free, no expiry on a paid plan. Pass"never"for permanence, which a paid plan honours and a capped plan refuses (409 expiry_clamped) rather than quietly turning into 7 days;accept_clamptakes the ceiling. Whatever you get,expires_at_isoandttl_secondsare in the response. - Safety scan in
blockmode (raw API:warn) — blocks accidental publication of personal data or secrets. The scan protects your content; nothing is collected or stored. - Unlisted token URL, strict CSP (Google Fonts + named video embeds allowed; remote scripts and images blocked),
X-Robots-Tag: noai— same defaults as the raw API.
WebMCP (in-browser)
The dashboard registers the same verbs on document.modelContext where the browser supports it (Chrome origin trial), so an in-browser agent helping a signed-in human can call them without an API key. How and why.
Want the same pattern in your own app? The palette and WebMCP registration are built on agentk, our open-source cmdk extension: define tools once as JSON Schema, humans get generated forms, agents get the schemas.
Agent payments
An agent pays with no human and no account, two ways: per publish, or with a persistent key bought once. Pay-per-publish accepts x402 (USDC on Base or Solana) and MPP (USDC.e on Tempo at the front-door, the evm method on Base); the persistent key is x402 (USDC on Base or Solana) or MPP on Base. Gasless for the payer either way. Read the accepts array from the 402 rather than hardcoding a rail. Machine-readable version: x402.md.
Pay per publish (no key)
The simplest path, when there is no key and no human to make one. POST your HTML to api.stacktr.ee/publish or to the front-door at agents.stacktr.ee/api/publish, get a 402, pay $0.50 over x402 (USDC on Base or Solana) or MPP, and the page publishes to a private link with the URL in the response. No provisioning step. Read the accepts array from the 402 rather than hardcoding a rail: Base is always the first entry, and the requirement differs per network. Free revisions afterwards are keyless for an EVM payer (the wallet that paid signs for them) and claim-token based for a Solana payer. The endpoints are listed on x402scan and mppscan; specs at api.stacktr.ee/openapi.json and agents.stacktr.ee/openapi.json, with a free proof-of-service record at api.stacktr.ee/.well-known/x402-service.
Persistent key: provision once, then pay as you go
Lists the accepted payment rails.
402 → pay $1.00 over x402 (USDC on Base or Solana) or MPP → persistent stk_live_ key, no account. The key carries free-tier limits: 3 pages in total, each expiring after 7 days, no email gates (passcodes do work). Lift them with an unlock below.
The à-la-carte catalog: make-permanent $5 per page, custom domain $5/30d, higher limits $25/30d.
402 → pay → feature entitlement on your key.
No wallet? Returns a pay link + terminal QR. A human pays by card in two taps — or an agent with a human-approved virtual card (Stripe link-cli, US Link accounts) fills the standard Stripe Checkout itself. Poll GET /pay/sessions/:code/poll.
Paying above the price in a pay session (up to $20) leaves a prepaid balance on the key that later paid actions draw from silently. Balances never expire and are refundable on request.
Claim what an agent published
The wallet that pays at the front-door is recorded with each page, so it doubles as a claim ticket. A human can link that wallet from the dashboard (generate a code, the agent signs it), or the agent can self-link with the link_wallet MCP tool. Every page the wallet published then becomes owned and manageable.
Update with the page's own claim token (no key, any chain)
Every unclaimed page carries a claim_token, returned in its publish response. Until the page is claimed, that token is the page's update credential: PUT /sites/:id with Authorization: Claim <claim_token> and a JSON body {"html": "…"} (multipart works too). Same URL, free revisions, no account, no wallet and no signature, so it is the path for a Solana payer and for a free anonymous publish alike. It covers exactly that page's content: not claiming, not deletion, not settings, not any other page. Claiming rotates the token into the account and the header stops working; it is also refused once the page expires. Treat the token as being as sensitive as the page: whoever holds it can replace the content behind a link you already sent.
/sites/:idOrSlugUpdate with the wallet itself (no key)
The paying wallet is also the page's update credential — no claim, no account, no API key. POST /wallet-auth/challenge with {"wallet":"0x…"}, personal_sign the returned message, then PUT /sites/:id with Authorization: Wallet challenge=…,sig=0x… and the JSON body. Same URL, free revisions; challenges are single-use with a 5-minute TTL. EVM EOA wallets only for now (no smart-contract wallets).
/wallet-auth/challengeEverything one wallet has paid for
A publish paid from a wallet returns next.receipt_url, a stable link to a page listing every page that wallet has paid for: title, URL, date, the settlement on-chain, whether the claim token is still live, the exact request that revises each one, and a button to move them all into an account. The link is the credential and the payer address is never accepted in its place: addresses are public on-chain, so an address-keyed page would make every x402 customer's private page titles enumerable from a block explorer. It is not returned by POST /wallet-auth/challenge for the same reason, and it is deliberately absent from the 402 challenge, which indexes crawl and republish.
Custom domains
Paid plans (Solo 1 domain, Studio 10, Firm 25), or the custom_domain x402 unlock. Bring your own hostname (docs.acme.com et al), point a CNAME at our Cloudflare for SaaS fallback origin, prove ownership via a TXT record, and traffic to that hostname serves your site over HTTPS.
/custom-domainscurl -X POST https://api.stacktr.ee/custom-domains \
-H "Authorization: Bearer stk_live_..." \
-H "Content-Type: application/json" \
-d '{"hostname":"docs.acme.com","site_id":"abc123"}'
Response includes a verify_token and the DNS records you still need to add. Two for a fresh hostname; if a verified parent-domain claim already covers it, instructions is null (subdomain: the wildcard CNAME does the routing) or CNAME-only (the claimed name itself, whose ownership is already proven):
{
"hostname": "docs.acme.com",
"site_id": "abc123",
"verified": false,
"instructions": {
"cname": { "name": "docs.acme.com", "value": "proxy.stacktr.ee", "type": "CNAME" },
"txt": { "name": "_stacktree-verify.docs.acme.com", "value": "verify_", "type": "TXT" }
}
}
/custom-domains/:hostname/verifyAfter adding the DNS records, call verify. We DNS-lookup the TXT record; on match we register the hostname with CF for SaaS and SSL provisioning begins (~60 s).
curl -X POST https://api.stacktr.ee/custom-domains/docs.acme.com/verify \
-H "Authorization: Bearer stk_live_..."
Gotcha — DNS-only CNAME. If your DNS is on Cloudflare, the CNAME must be set to DNS only (grey cloud), not Proxied (orange). A proxied CNAME makes Cloudflare claim the hostname for your own zone and Stacktree's SaaS routing never sees the SNI.
Re-bind or delete
PATCH https://api.stacktr.ee/custom-domains/:hostname # { "site_id": "..." } — re-bind
DELETE https://api.stacktr.ee/custom-domains/:hostname # unregister + drop row
List your domains with GET https://api.stacktr.ee/custom-domains. Unverified rows are auto-pruned after 7 days.
OAuth (custom connector authors)
For MCP host implementers — if you're using a maintained client (Claude.ai, Cursor, etc.) skip this section.
Discovery
GET https://api.stacktr.ee/.well-known/oauth-authorization-server
GET https://api.stacktr.ee/.well-known/oauth-protected-resource
Both return standard RFC 8414 / RFC 9728 metadata documents.
Flow
OAuth 2.1 with PKCE (S256 required) and Dynamic Client Registration (RFC 7591). Endpoints:
DCR — rate-limited to 10/IP/hour.
Bounces to Clerk-gated consent page on app.stacktr.ee.
Code → access token (HS256 JWT, 30-day TTL).
RFC 7009 revocation.
Callback for hosted Claude surfaces: https://claude.ai/api/mcp/auth_callback.
Limits
Every number below is enforced server-side from one table. GET /me returns the caller's own limits object; read it from there rather than hard-coding a cap in a client.
| Limit | Anonymous | Free | Solo $19 | Studio $79 | Firm $249 |
|---|---|---|---|---|---|
| Pages | — | 3 in total | 25 active | unlimited | unlimited |
| Page lifetime | 24h | 7 days, always | permanent | permanent | permanent |
| Publishes / 24h | 20 per IP | 50 | 1,000 | 1,000 | unlimited |
| Per-site size | 10 MB | 25 MB | 250 MB | 250 MB | 1 GB |
| Files / archive | 1,000 | 1,000 | 1,000 | 1,000 | 1,000 |
| Passcodes · email gates | passcode only | passcode only | ✓ | ✓ | ✓ |
| Viewer numbers | — | — | opens, views, last opened | + full engagement | + full engagement |
| Custom slug | — | ✓ | ✓ | ✓ | ✓ |
| Custom domains | — | — | 1 | 10 | 25 |
| Client spaces | — | — | 1 | 10 | unlimited |
| Stacktree badge | stays | stays | removed | removed | removed |
The client-spaces row counts ACTIVATED spaces, meaning ones with a hostname bound or a portal enabled. Filing pages under a client is free on every plan, Free included: the paid unit is the address, not the label. Archiving a space frees the slot, and a space hostname does not consume a custom-domain slot.
Free counts publishes, not live pages. The 3 is lifetime_publishes, a counter that only goes up: deleting a page or letting it expire does not give the slot back. Solo's 25 is the other model: active pages, freed by deleting. Every free page expires 7 days after it is published, and passing expires_in_hours: "never" is refused rather than quietly landing on the ceiling: an agent that gets a 201 tells its user the link is permanent, so the answer has to be an error it cannot mistake for success. accept_clamp publishes it with the 7 days.
DCR rate limit: 10 client registrations / IP / hour. An expired page stops serving within the hour and the URL then says the link expired, exactly as before; what changed is what happens next. The content is kept for 30 days rather than destroyed at that moment, so POST /sites/:idOrSlug/restore can put it back at the same URL, and only then is it purged from R2 and D1 for good.
Enterprise is custom and annual (self-hosting, DPA, SLA, data residency). Ask at gm@stacktr.ee. Accounts on the older Pro and Agent plans keep the limits they signed up on; neither is sold any more.
No plan? The higher_limits unlock ($25 / 30 days over x402) lifts a free identity to fleet limits: 1 GB per site, unlimited daily publishes, no page cap, and pages that do not expire. make_permanent ($5, one page) cancels the expiry on a single page instead.
Plan errors
Each carries a stable error code, the caller's plan, a human message, and where relevant a limit. Surface them as an upgrade prompt, not a raw string:
| Code | Status | Means |
|---|---|---|
plan_lifetime_limit_exceeded | 402 | All 3 free pages used. Deleting one does not help. |
plan_site_limit_exceeded | 402 | Active-page cap reached (Solo). Delete one, or move up. |
plan_password_not_available | 402 | Passcodes are not on this plan (unknown plans only; every real plan has them). |
plan_password_limit_exceeded | 402 | Passcode-protected page cap reached. |
plan_viewer_gate_not_available | 402 | Email gates are not on this plan. |
plan_viewer_gate_limit_exceeded | 402 | Email-gated page cap reached. |
plan_domain_not_available | 402 | Custom domains are not on this plan. |
plan_domain_limit_exceeded | 402 | Custom-domain cap reached. |
plan_space_not_available | 402 | Activating a client space (address or portal) is not on this plan. Filing pages under a client still works. |
plan_space_limit_exceeded | 402 | Activated client-space cap reached. Archive or deactivate one, or move up. |
plan_upload_limit_exceeded | 429 | Daily publish cap reached; resets on a rolling 24h window. |
Expiry is half an exception. A page lifetime longer than the plan ceiling is clamped rather than refused: it comes back shortened in expires_at, with expiry_clamped: true and no error. Asking for a page that never expires on a capped plan is refused instead, 409 expiry_clamped, because that is the one case where the difference gets repeated to a person as "this link is permanent". accept_clamp takes the ceiling in one field.
Every response that carries a page carries its deadline six ways: expires_at (unix seconds, unchanged), expires_at_iso (RFC 3339 UTC, the one to show a human), ttl_seconds (never negative), expiry_clamped, expiry_ceiling_hours (24 anonymous, 168 Free, null on plans with no ceiling) and expiry_source (request, plan_ceiling, plan_default or stored).