Sites

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

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.

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

Request body

htmlstring required

The full replacement document, as a string. One text file; anything binary or multi-file goes over multipart/form-data.

filenamestring

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_atinteger

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

Replaced.

idstring required

Stable site identifier.

slugstring nullable

Public subdomain slug, if the site is public.

visibility'unlisted' | 'public' | 'burn' required

Privacy mode.

urlstring uri

Canonical URL to view the site.

created_atinteger required

Unix epoch seconds.

updated_atinteger

Unix epoch seconds.

expires_atinteger 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_isostring 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_secondsinteger 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_clampedboolean

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_hoursinteger 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_sizeinteger

Total content size in bytes.

metrics_lockedboolean

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.

openedboolean

Whether anyone has opened the page. Returned on every plan, including when metrics_locked is true, so "someone read this" survives the lock.

badgedboolean

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_countinteger nullable

Raw view count. Null when metrics_locked.

unique_viewersinteger nullable

Distinct human viewers over the last 30 days. Null when metrics_locked.

last_viewed_atinteger nullable

Unix epoch seconds of the most recent open. Null when metrics_locked, or when never opened.

client_urlstring

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.

claim_tokenstring 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_urlstring 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_urlstring 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_atinteger 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_reasonstring 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_untilinteger 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.

Changes

No recorded changes to this operation across all 1 revision of this API.