Stacktree

HTML auf eine private, unerratbare URL von jedem MCP-Client aus veröffentlichen. Durch Passwort oder E-Mail-Domain absichern; direkt ersetzen.

Dokumentation

Stacktree API documentation — developer resources

Source: https://stacktr.ee/docs

[

  Stacktree docs
](https://stacktr.ee/)

  [Agents](https://stacktr.ee/agents)
  [Docs](https://stacktr.ee/docs)
  [Use cases](https://stacktr.ee/use-cases)
  [Pricing](https://stacktr.ee/pricing)
  [Blog](https://stacktr.ee/blog)

[Dashboard](https://app.stacktr.ee)

Search docs⌘K

  Getting started

    - [Connect an agent](#connect)

        [CLI installer](#installer)

        - [Claude.ai](#claude-ai)

        - [Claude Code · Codex](#claude-code)

        - [MCP config file](#mcp-config)

        - [Slack](#slack)

  Reference

    - [HTTP API](#api)

        [Auth](#auth)

        - [POST /sites](#post-sites)

        - [PUT /sites/:id](#put-sites)

        - [PATCH /sites/:id](#patch-sites)

        - [List · raw · delete · restore](#more-sites)

        - [Share tokens](#share-tokens)

        - [API keys](#keys)

        - [Device code](#device-code)

    - [MCP server](#mcp)

    - [Agent payments](#payments)

    - [Custom domains](#domains)

    - [OAuth](#oauth)

    - [Limits](#limits)

        [Plan errors](#plan-errors)

  Machine-readable

    - [llms.txt](https://stacktr.ee/llms.txt)

    - [pricing.md](https://stacktr.ee/pricing.md)

    - [auth.md](https://stacktr.ee/auth.md)

    - [x402.md](https://stacktr.ee/x402.md)

[Changelog](https://stacktr.ee/changelog) · [Blog](https://stacktr.ee/blog)

[Dashboard](https://app.stacktr.ee) · [Pricing](https://stacktr.ee/pricing)

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](https://claude.ai/settings/connectors) → Add custom connector.

- Paste https://api.stacktr.ee/mcp as 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:

  Authorization: Bearer stk_live_…API key
  Create at [app.stacktr.ee/api-keys](https://app.stacktr.ee/api-keys) — or let an agent buy its own over [x402 or MPP](#payments).

  Authorization: Bearer <clerk-session-jwt>session
  Clerk session token, for dashboard-originated calls.

  Authorization: Bearer <oauth-jwt>OAuth
  Access token from `/oauth/token`, used by custom connectors.

POST/sites Upload a single HTML/markdown file or a zip. multipart/form-data. Anonymous uploads work — no auth header — and live 24 hours.

  filefilerequired
  .html / .htm / .md / .zip

  public_slugstring
  Opt-in public subdomain; authed only.

  passwordstring
  Passcode gate on serve. Works on every plan, including all 3 free-plan pages and anonymous publishes.

  expires_in_hoursnumber | "never"
  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.

  accept_clamp"true"
  Only needed alongside `expires_in_hours=never` on a plan that caps page lifetime: it says the ceiling is acceptable and publishes.

  Idempotency-Keyheader
  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.

  burn_after_read"true"
  Delete after first view.

  agentation"true"
  Inject feedback toolbar on serve.

  csp_strict"false"
  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.

  e2e"true"
  Treat upload as ciphertext; the decryption key lives in the URL fragment and is never sent to Stacktree.

  pii_checkoff | warn | block
  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
}`

PUT/sites/:idOrSlug Replace 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.

PATCH/sites/:idOrSlug Update settings without re-uploading files. JSON body.

  passwordstring | null
  Set or remove the passcode gate. Works on every plan; removing one always works too.

  expires_in_hoursnumber | null
  `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.

  allowed_email_domainstring | null
  Viewers verify an email on that domain before the page renders. Paid plans only; Free gets `402 plan_viewer_gate_not_available`.

  public_slugstring | null
  Claim or release a public subdomain.

  agentation · burn_after_read · csp_strictboolean
  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

  GET/sites
  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.

  GET/sites/:idOrSlug
  One site, with file manifest + absolute `preview_url`.

  GET/raw/:token
  Page HTML stripped of head/scripts — clean text for re-feeding into an agent. Honors password gates.

  DELETE/sites/:idOrSlug
  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.

  POST/sites/:idOrSlug/restore
  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](#payments) at POST https://api.stacktr.ee/provision, or attach itself to a human's existing account with the [device-code flow](#device-code) 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](#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)

  publish_html&rarr; { id, url, slug, unlisted_token, … }
  Turn HTML into a link a person can open in a browser.

  update_site&rarr; { id, url, file_count, size_bytes }
  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.

  delete_site&rarr; { ok, restorable_until, already_deleted }
  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.

  restore_site&rarr; { ok, id, url, expires_at, … }
  Put a page back at the same URL after it was deleted or ran out of time.

  claim_site&rarr; { ok, id, expires_at, expires_at_iso, … }
  Adopt a page that was published without an account into the account this connection is authenticated as.

  set_password&rarr; { ok }
  Set or clear (null) a viewer passcode on a site. Works on every plan (free covers its 3 pages).

  set_expiry&rarr; { ok, expires_at, expires_at_iso, ttl_seconds, … }
  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).

  create_share_link
  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.

  list_share_links
  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.

  revoke_share_link&rarr; { ok }
  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.

  set_agentation&rarr; { ok }
  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.

  set_email_gate&rarr; { ok }
  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&rarr; { sites }
  List sites owned by the authenticated user, newest-updated first.

  get_me&rarr; { clerk_user_id, auth_method, plan, email, … }
  Who this connection is publishing as, and what its plan actually allows.

  list_client_spaces&rarr; { spaces }
  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).

  set_client&rarr; { ok }
  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_client_space&rarr; { ok, space }
  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.

  update_client_space&rarr; { ok }
  Rename a client space, archive or unarchive it, or set the viewer gate that covers every page in the space.

  delete_client_space&rarr; { ok, detached_pages }
  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.

  get_design_guide&rarr; { guide, version }
  Fetch the Stacktree house design guide for improving a published page.

  get_site&rarr; { html }
  Read the current HTML source of a site you own, so you can edit it and call update_site to change it in place.

  get_content&rarr; { content, format }
  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…

  list_feedback&rarr; { feedback }
  Read viewer feedback left on a site via the Agentation toolbar (enable it with set_agentation).

  resolve_feedback&rarr; { ok }
  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_wallet&rarr; { ok }
  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_hours takes 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_clamp takes the ceiling. Whatever you get, expires_at_iso and ttl_seconds are in the response.

  - Safety scan in block mode (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

  GET/provision
  Lists the accepted payment rails.

  POST/provision
  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.

  GET/unlock
  The à-la-carte catalog: make-permanent $5 per page, custom domain $5/30d, higher limits $25/30d.

  POST/unlock
  402 → pay → feature entitlement on your key.

  POST/pay/sessions
  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.

PUT/sites/:idOrSlug

Update 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).

POST/wallet-auth/challenge

Everything 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.

POST/custom-domains

`curl -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" }
  }
}`

POST/custom-domains/:hostname/verify After 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:

  POST/oauth/register
  DCR — rate-limited to 10/IP/hour.

  GET/oauth/authorize
  Bounces to Clerk-gated consent page on app.stacktr.ee.

  POST/oauth/token
  Code → access token (HS256 JWT, 30-day TTL).

  POST/oauth/revoke
  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.

LimitAnonymousFreeSolo $19Studio $79Firm $249
Pages—3 in total25 activeunlimitedunlimited
Page lifetime24h7 days, alwayspermanentpermanentpermanent
Publishes / 24h20 per IP501,0001,000unlimited
Per-site size10 MB25 MB250 MB250 MB1 GB
Files / archive1,0001,0001,0001,0001,000
Passcodes · email gatespasscode onlypasscode only✓✓✓
Viewer numbers——opens, views, last opened+ full engagement+ full engagement
Custom slug—✓✓✓✓
Custom domains——11025
Client spaces——110unlimited
Stacktree badgestaysstaysremovedremovedremoved

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:

CodeStatusMeans
`plan_lifetime_limit_exceeded`402All 3 free pages used. Deleting one does not help.
`plan_site_limit_exceeded`402Active-page cap reached (Solo). Delete one, or move up.
`plan_password_not_available`402Passcodes are not on this plan (unknown plans only; every real plan has them).
`plan_password_limit_exceeded`402Passcode-protected page cap reached.
`plan_viewer_gate_not_available`402Email gates are not on this plan.
`plan_viewer_gate_limit_exceeded`402Email-gated page cap reached.
`plan_domain_not_available`402Custom domains are not on this plan.
`plan_domain_limit_exceeded`402Custom-domain cap reached.
`plan_space_not_available`402Activating a client space (address or portal) is not on this plan. Filing pages under a client still works.
`plan_space_limit_exceeded`402Activated client-space cap reached. Archive or deactivate one, or move up.
`plan_upload_limit_exceeded`429Daily 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).


Full markdown summary of the Stacktree marketing surface: https://stacktr.ee/llms-full.txt