---
title: "Replace the site contents (full re-upload, same URL)."
method: PUT
path: "/sites/{idOrSlug}"
tags: ["Sites"]
---

# Replace the site contents (full re-upload, same URL).

`PUT /sites/{idOrSlug}`

## Request

- Base URL: `https://api.stacktr.ee`
- URL: `https://api.stacktr.ee/sites/{idOrSlug}`
- 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`)
  - API key in header `Authorization`
  - API key in header `Authorization`

Two body shapes. `application/json` with an `html` string is the simplest and mirrors POST /publish, which is what the paid rails already speak: `curl -X PUT https://api.stacktr.ee/sites/<id> -H "Content-Type: application/json" -d '{"html":"<!doctype html>..."}'`. `multipart/form-data` with a `file` field takes everything JSON cannot carry (a zip, a PDF, e2e ciphertext): `curl -X PUT https://api.stacktr.ee/sites/<id> -F file=@new.html -H "Authorization: Bearer <key>"`. Either shape keeps the existing URL while swapping the content, and keeps the site's settings — including its client-space filing — untouched (`client`/`client_path` are not accepted here; re-file with PATCH). Replacing a page does not count as a new publish against the Free lifetime cap. Two keyless alternatives, neither of which needs an account: for a page that is still UNCLAIMED, its own `claim_token` from the publish response is the update credential (`Authorization: Claim <claim_token>`, described in the claimToken security scheme; works on every rail, and stops working at the claim and at expiry); for a wallet-paid page, the paying wallet is (see walletSignature: challenge from POST /wallet-auth/challenge, then `Authorization: Wallet challenge=…,sig=0x…`). Either way the URL is unchanged: replacing a page never mints a second one. Send exactly one Authorization scheme per request.

## Request body

- object
  - `html` string, required: The full replacement document, as a string. One text file; anything binary or multi-file goes over multipart/form-data.
  - `filename` string: Optional logical filename, default index.html. Set it to replace a page published as a machine asset (data.json, feed.xml) at its own path.
  - `expected_updated_at` integer: Optimistic-concurrency precondition: the updated_at this replacement was derived from. A mismatch returns 409 and changes nothing.
  - `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 `200`

Replaced.

- 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

- `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.
- `403`: The credential presented does not authorise this page. `invalid_claim_token`: the claim token is wrong, or the page has been claimed and now needs the owning account's credential. `wallet_blocked` / `blocked`: the actor is on the abuse blocklist.
- `404`: Resource not found.
- `409`: The page cannot take this write in its current state. Read `error`: `site_deleted` means it has stopped serving and is being kept for 30 days: restore it with POST /sites/{idOrSlug}/restore and retry, and do NOT publish it again, which would create a second page at a different URL. `managed_portal` means it is a generated client portal that rebuilds from its space. `slug already taken` means another page holds that public slug, including a retired page still reserving it for its restore window.
- `410`: The page stopped serving and cannot be updated. `expired`: an unclaimed page reached its deadline; it is not restorable (restoring is an account action and the page never had an owner), so publish a new one rather than retrying. `burned`: burn-after-read consumed it.
- `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.
- `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)
