---
title: "Publish a new site."
method: POST
path: "/sites"
tags: ["Sites"]
---

# Publish a new site.

`POST /sites`

## Request

- Base URL: `https://api.stacktr.ee`
- URL: `https://api.stacktr.ee/sites`
- Auth: one of:
  - HTTP bearer
  - OAuth 2 (authorization code, authorization URL `https://api.stacktr.ee/oauth/authorize`, token URL `https://api.stacktr.ee/oauth/token`, scopes: `sites:write`)
  - none

Two body shapes. `application/json` with an `html` string (or `markdown`) plus any of the optional fields below, the simplest for an agent writing its own HTTP call: `curl -X POST https://api.stacktr.ee/sites -H "Content-Type: application/json" -d '{"html":"<!doctype html>..."}'`. Or `multipart/form-data` with a `file` field (a single .html/.htm/.md document, or a .zip containing index.html at the root), the only shape for a zip, a PDF, an image or e2e ciphertext. Agents working for one person can add `owner_email` (that person's address, with their permission: on a page published without an account they are emailed the link that keeps it, and once more when someone else first opens it) and `agent` (the assistant's own name, shown to the owner). Anonymous publishing works with no Authorization header from a direct client connection: the site gets a 24h TTL, can be up to 10 MB, and is rate-limited per IP. An authenticated publish counts against the plan: Free allows 3 pages in total (a monotonic counter, so deleting a page does not free the slot) and expires every page after 7 days. Example: `curl -X POST https://api.stacktr.ee/sites -F file=@page.html`. An anonymous publish returns a `claim_token`: it adopts the page into an account (POST /sites/{idOrSlug}/claim) AND, until it is used for that, it is the page's keyless update credential on PUT /sites/{idOrSlug} (`Authorization: Claim <claim_token>`), so a curl-only agent can keep one URL current without an account. Store it with the page id; it is as sensitive as the page.

## Headers

- `Idempotency-Key` string

## Request body

- object: Content as a string, plus the same optional fields as the multipart form (password, recipient, client, expires_in_hours as a number of hours or "never", public_slug, comments, reactions, owner_email, agent). Values may be strings, numbers or booleans.
  - `html` string: The document, as a string. Required unless `markdown` is sent.
  - `markdown` string: A Markdown document instead of HTML; rendered as a page.
  - `filename` string: Optional, default index.html (index.md for markdown).
  - `owner_email` string, email: The person the page is for, as the publishing agent knows them. On a page published without an account, Stacktree emails them the link that keeps it, and once more when someone else first opens it (named open, or a second viewer). Ignored on an account publish. Send only with their permission.
  - `agent` string: The publishing assistant's own name, a plain slug (muse, instinct, poke, dots, grok). Shown to the owner; analytics only.
  - `password` string
  - `recipient` string: Who the page is for, by name; the response carries their named link.
  - `expires_in_hours` union: Hours, or "never".
    - integer
    - string
  - `pii_check` 'warn' | 'block' | 'off': Pre-flight scan for email addresses, card numbers, social security numbers, API keys and access tokens. "warn" (the default) publishes and says what it found in `pii_warning` (and the X-Stacktree-Pii-Warning header). "block" refuses with 422 `pii_detected` and publishes nothing; the MCP tools default to block. "off" skips the scan.

## Response `201`

Published. Also returned verbatim on an idempotent replay, with `Idempotent-Replay: true`. A page published without an account also carries `claim_token`, `keep_url`, and `next.status` (how to ask whether it has been opened); with `owner_email`, an `owner_email` object saying whether the keep link was sent.

- Site
  - `id` string, required: Stable site identifier.
  - `slug` string, nullable: Public subdomain slug, if the site is public.
  - `visibility` 'unlisted' | 'public' | 'burn', required: Privacy mode.
  - `url` string, uri: Canonical URL to view the site.
  - `created_at` integer, required: Unix epoch seconds.
  - `updated_at` integer: Unix epoch seconds.
  - `expires_at` integer, nullable: Unix epoch seconds; null = no expiry. Clamped to the plan ceiling at write time (Free: 7 days, anonymous: 24 hours), so this can come back sooner than you asked for.
  - `expires_at_iso` string, nullable: The same instant as `expires_at`, RFC 3339 UTC, second granularity (e.g. "2026-09-08T12:00:00Z"). Present so a client can show a deadline without reconstructing it from the epoch.
  - `ttl_seconds` integer, nullable: Seconds from now until the page stops serving. Null when it does not expire; 0 when it is already out of time. Never negative.
  - `expiry_clamped` boolean: True when the lifetime THIS request asked for was longer than the plan allows and `expires_at` is the shortened reality. Always present, false included. False on reads, which ask for no lifetime. Asking for a page that never expires on a capped plan is refused (409 `expiry_clamped`) rather than clamped, unless `accept_clamp` is sent.
  - `expiry_ceiling_hours` integer, nullable: The longest lifetime this plan allows a page, in hours: 24 anonymous, 168 on Free, null on plans with no ceiling and on the paid one-off rails.
  - `expiry_source` 'request' | 'plan_ceiling' | 'plan_default' | 'stored': What decided the date: `request` (the caller named it), `plan_ceiling` (the plan cap did), `plan_default` (the plan default did), or `stored` (this call only read the row).
  - `byte_size` integer: Total content size in bytes.
  - `email_gate` object, nullable
    - `domain` string
    - `required` boolean
  - `metrics_locked` boolean: True when the plan has no viewer numbers (anonymous and Free). The three viewer fields below are then null; there is no number to reveal, blur, or approximate.
  - `opened` boolean: Whether anyone has opened the page. Returned on every plan, including when metrics_locked is true, so "someone read this" survives the lock.
  - `badged` boolean: Whether THIS page carries the "Made with Stacktree" footer as served on its stacktr.ee URL. Returned by GET /sites/{idOrSlug} only. A fact about the page, not about the plan: pages published before 2026-08-03, generated client portals and end-to-end encrypted pages carry no badge even on Free, and a remove_badge entitlement lifts it off one page or a whole account. Never assume it from the plan; anything offering to remove the badge should read this.
  - `view_count` integer, nullable: Raw view count. Null when metrics_locked.
  - `unique_viewers` integer, nullable: Distinct human viewers over the last 30 days. Null when metrics_locked.
  - `last_viewed_at` integer, nullable: Unix epoch seconds of the most recent open. Null when metrics_locked, or when never opened.
  - `reading` object, nullable: Returned by GET /sites. How closely the newest named reader read this page: their visits and reading time, the sender's own previews excluded. Null when that open carried no name, or when metrics_locked. `seconds` is null on a plan without reading detail (Solo).
    - `by` string
    - `others` integer: How many other named readers opened it.
    - `visits` integer
    - `seconds` integer, nullable
  - `client` object, nullable: Client space this page is filed under, or null for a floating page.
    - `slug` string
    - `name` string
  - `client_url` string: Present on publish when the space has a connected hostname and the page got a space_path: the page’s address on the client’s own domain (e.g. https://acme.theiragency.com/june-report). Prefer handing this link to the user.
  - `recipient` object, nullable: Present on publish when the request named who the page is for (`recipient`). `link` is their own link (`?to=` on the page URL): hand that over rather than `url`, and every open through it comes back with their name. On GET /sites: the newest person the page was named for, on every plan (whether they opened it is a viewer number and is not here), or null.
    - `name` string
    - `link` string
    - `added_at` integer: GET /sites only. Unix seconds when they were named.
    - `others` integer: GET /sites only. How many other people the page was named for.
  - `claim_token` string, nullable: Anonymous publishes only. Adopts the page into an account (POST /sites/{idOrSlug}/claim) and, until then, is its keyless update credential. As sensitive as the page.
  - `claim_url` string, uri: Anonymous publishes only. The dashboard claim screen for this page: sign in, and the page moves into that account at the same address.
  - `keep_url` string, uri: Anonymous publishes only. Give this to the person you published for: it opens a page that says when this page stops working and offers both ways to keep it (a free account, or the link by email); opening it keeps nothing until they choose one. It also marks their browser so the page itself shows them a Keep button. It carries the claim token, so it is as sensitive as claim_url: share `url`, never this.
  - `deleted_at` integer, nullable: Unix epoch seconds the page stopped serving; null when it is live. Non-null means the URL is dead for everyone holding it.
  - `delete_reason` string, nullable: Why it stopped serving: `expired`, `owner` (someone deleted it), `abuse` (a takedown), or `merged` (folded into another page; see `merged_into`). null while the page is live. `abuse` and `merged` are never restorable: POST /sites/{idOrSlug}/restore refuses them, so do not offer a restore control for either.
  - `restorable_until` integer, nullable: Unix epoch seconds. A real deadline, not a hint: the hourly cleanup destroys the content at that moment and restore returns 404 from then on.
  - `likely_revision_of` object: On POST /sites only, and only when the page looks like a new version of one this account already has: the same title (changed in the last 24 hours, same client or none) or the same client and client_path (last 30 days). The page was still published, as asked, at a new link; this names the earlier page and the call that would have kept its link (`hint`). Next time, PUT /sites/{id} on it instead of publishing again. When `can_merge` is true, POST /sites/{newId}/merge folds the two together now. Ignore it if this is a separate document.
    - `id` string
    - `url` string, uri
    - `title` string, nullable
    - `matched_on` 'title' | 'client_path'
    - `last_changed_at` integer: Unix epoch seconds the earlier page last changed.
    - `last_changed_at_iso` string, nullable
    - `minutes_ago` integer
    - `client` object
      - `slug` string
      - `name` string
    - `can_merge` boolean: Whether POST /sites/{newId}/merge would fold the earlier page into this one now.
    - `hint` string: The same facts in a sentence, safe to repeat.
  - `merged_into` object, nullable: On GET /sites/{idOrSlug} for a page with delete_reason `merged`: the page that holds its content now, and that its link opens.
    - `id` string
    - `url` string, uri
    - `title` string, nullable

## Other responses

- `400`: Invalid request (e.g. neither JSON nor multipart/form-data, missing `html` or file, `e2e_requires_multipart`, an anonymous upload without a direct client IP, `invalid_expiry` for a lifetime that is neither a positive number of hours nor "never", `invalid_idempotency_key`, or `idempotency_key_too_weak` for a guessable key on an anonymous publish).
- `402`: A plan ceiling was hit. The feature or the quota is not on this plan; the caller needs a different plan or an x402 unlock.
- `409`: Nothing was published. `expiry_clamped`: you asked for a page that never expires on a plan that caps page lifetime; the body carries `would_expire_at` / `would_expire_at_iso`, and `accept_clamp` publishes it with that deadline. `idempotency_key_in_progress`: a publish with this key is already running; retry after `retry_after_seconds` with the identical request. `idempotent_page_gone`: the page this key already published has been deleted or burned, so there is nothing to replay; restore it, or use a NEW key for new content. None of the three is a reason to publish again without the key, which would create a second page.
- `413`: File exceeds the per-page size cap: 10 MB with no account or for a wallet-paid page, 25 MB on Free, 250 MB on paid plans (1 GB on Firm). Nothing was published; the body carries `plan`, `limit_bytes` and how to shrink the page (usually inline images).
- `422`: `pii_detected` (only with pii_check "block"): the page looks like it holds personal data or a secret. Nothing was published; `message` names what was found, redacted, and how to publish, and `hits` lists each kind with a count. `idempotency_key_reused`: this Idempotency-Key already belongs to different content. Nothing was published, and the earlier page is deliberately NOT returned: serving it would report success for content that was never published. Retry with a new key, or resend the original body unchanged.
- `429`: Too many requests, or the daily publish cap for the plan (plan_upload_limit_exceeded). Carries Retry-After plus the RateLimit-Limit/-Remaining/-Reset headers.

---

[API](https://skmtc.dev/stacktr/apis/stacktree-api.md) · [All operations](https://skmtc.dev/stacktr/apis/stacktree-api/llms.txt) · [OpenAPI document](https://skmtc.dev/stacktr/apis/stacktree-api/revisions/5878cce46977?raw)
