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 41 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 runs inline scripts and libraries from cdnjs, the Tailwind CDN and npm packages on jsDelivr or unpkg (so Claude and ChatGPT artifacts render as they did there), allows Google Fonts and other font services, Unsplash images and Loom / YouTube / Vimeo / Wistia / Descript / Streamable / Calendly embeds, and blocks other remote scripts and images and any request to another site. 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 before the page renders. Domains and/or single addresses, separated by commas ("acme.com, jane@globex.com"): a domain admits everyone at it, an address only that person. 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.
The page as Markdown (headings, lists, tables and links kept) for re-feeding into an agent. Refuses gated pages; for those use the page's own address below.
Accept: text/markdownThe same link a person was sent, read as Markdown. Also at {page URL}/_page.md, and advertised by a Link: rel="alternate" header and a <link> in the head. Because it is the page's own address, a passcode or space gate applies exactly as it does to the HTML: an agent sends the passcode as HTTP Basic auth and gets the Markdown.
"Ask this page", where the owner has turned it on: { "q": "..." } in, a stream of newline-delimited JSON out ({"t": text} chunks, {"c": cited passage}, then {"done": true, "grounded": bool}). Answers come from the page only and say so when the page does not cover the question. Also exposed to browsing agents as the WebMCP tool stacktree_ask_page. What readers ask is listed for the owner at GET /sites/:idOrSlug/questions.
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 or PATCH). 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 41 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 (41)
Turn HTML into a link a person can open in a browser. It only ever creates a new page: it never changes or deletes one that already exists (update_site replaces a page in place).
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.
Restore a page that was deleted or expired in the last 30 days.
Adopt a page that was published without an account into the account this connection is authenticated as.
Set or clear (null) the passcode viewers type to open a page.
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 a separate link to one of the user's pages for a single recipient, labelled with who it is for.
List the share links on one of the user's pages: each link's id, label, expiry, use limit and whether it was revoked, with how many times it has been opened and when it was last opened.
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.
Switch how the people you send a page to can answer on it, with no account. comments: they select words, or click an image, video or section, and leave a comment only the owner sees (read them with list_feedback, fix the page with update_site, then…
Restrict viewer access to an email domain, to named people, or both.
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…
Show the passcode set on a page this account owns, so it can be sent to the client again.
List earlier versions of a page this account owns, newest first.
Put an earlier version of a page back at the same URL, after a bad update.
Read viewer comments left on a site, unresolved first.
Mark a feedback item as addressed after fixing the page. Pass the feedback item id from list_feedback, and a short note describing what you changed: the reviewer who left the comment sees it on the page.
Link your wallet to a Stacktree account so the pages you publish are owned there — and adopt the ones you already published.
Record who a published page is for, by name, and get back that person’s own link to hand over.
The pages, and the named people, that have not opened what was sent to them an hour after it went up, newest first.
Everything that came back on one page, as the brief for its next version: the reviewer's change requests, their questions grouped by theme, their approvals, the notes the owner left on their own page as a fix list, each with the exact passage or image it is…
Publishes the new version of a page that has feedback and resolves the comments it answers, in one step.
Resolves several comments on one page in one call, each with a short note, plus one optional message back to the reviewer.
Get a named person's yes on a page. Publishes the HTML (or uses a page already published) with a sign-off panel, and makes one private link per approver that carries their name.
Where an approval request stands. state is "approved" only when every approver has approved the version that is live now (the whole page, or every section); "changes_requested" when anyone has asked for a change; otherwise "pending".
What readers sent through the forms on a page. A form in published HTML marked <form data-stacktree-form="name"> posts to the page itself; only the fields the form declares are kept.
After update_site: resolve the open notes the new version appears to answer (comments whose words are gone, sections that were asked to change and have been edited), with one line each reviewer sees next to their own note.
The Stacktree viewer for an .html file the user opens in ChatGPT desktop: preview it, publish it to a private link, add a passcode, send it to a named client, and keep the page in sync as the file changes.
Lists the pages you have published on Stacktree, newest first, with their links, viewers and expiry.
Used by the Stacktree publish card in the chat to refresh itself: the page’s link, whether and by whom it has been opened, its passcode, expiry and client.
Used by the Stacktree publish card in the chat: a short-lived owner preview link for one page, which opens it without counting as a visit and without asking for its passcode.
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 (library CDNs, Google Fonts and named video embeds allowed; other 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/:idOrSlugKeep an anonymous page: the keep link, or email
An anonymous page belongs to nobody until it is claimed, and most of them are published by an agent whose human never sees the response. Every anonymous publish response carries keep_url: hand it to the person you published for. It opens a page that says when the page stops working and offers both ways to keep it, with no sign-in needed to read it, and it marks that browser so the page itself shows them, and only them, a Keep button until it expires. It carries the claim token, so share url, never keep_url.
Or email it: POST /sites/:id/keep with {"claim_token": "…", "email": "…", "consent": true} sends that address the claim link and the page's deadline, and one reminder as the page runs out. Ask the person first and send consent: true only when they said yes. No account and no key: the claim_token is the credential. The address is stored on the page, used for those two emails only, and cleared by the claim; one page can send at most three keep emails. A claim keeps working for the 30 days an expired page is held, and claiming an expired page brings it back at the same address, so a page that ran out at 24 hours is not lost until the hold ends.
/sites/:idOrSlug/keepUpdate 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, SLA, data residency). A standard DPA is available on request on any plan from security@stacktr.ee. 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).