{
  "components": {
    "parameters": {
      "create": {
        "description": "1 creates the site when you have none of that name (reserved names, the site quota and publish=false are refused as on POST), so one PUT deploys from CI whether or not the site exists",
        "in": "query",
        "name": "create",
        "required": false,
        "schema": {
          "default": "0",
          "enum": [
            "1",
            "0",
            "true",
            "false"
          ],
          "type": "string"
        }
      },
      "createIdempotencyKey": {
        "description": "Opaque key for a create request. For 24 hours, the same authenticated account, POST path and query, key and exact request body replay the original handler status and body, including errors, without executing it again. A different body under that key returns 409 idempotency_key_reused. Concurrent identical requests wait for the first response. Stored only in this server's memory; a restart clears it. Authentication and rate limiting still apply to replays. A replay does not extend a minted link's expiry; use a new key to request a new link. Storage row creates support owner credentials and signed-in visitors; anonymous writes ignore this header. Storage replay also checks current resource permissions and is scoped to the resolved site.",
        "in": "header",
        "name": "Idempotency-Key",
        "required": false,
        "schema": {
          "type": "string"
        }
      },
      "csrf": {
        "description": "Required on cookie-authenticated writes",
        "in": "header",
        "name": "X-SH-CSRF",
        "required": false,
        "schema": {
          "enum": [
            "1"
          ],
          "type": "string"
        }
      },
      "familySuffix": {
        "description": "The family: trips.example.com or *.trips.example.com (URL-escaped)",
        "example": "trips.example.com",
        "in": "path",
        "name": "suffix",
        "required": true,
        "schema": {
          "type": "string"
        }
      },
      "path": {
        "description": "Relative file path (may contain forward slashes)",
        "example": "assets/style.css",
        "in": "path",
        "name": "path",
        "required": true,
        "schema": {
          "type": "string"
        }
      },
      "publish": {
        "description": "false stores the new version without making it live (updates only; a new site's first version always goes live: 400)",
        "in": "query",
        "name": "publish",
        "required": false,
        "schema": {
          "default": true,
          "type": "boolean"
        }
      },
      "sitename": {
        "example": "my-cool-site",
        "in": "path",
        "name": "sitename",
        "required": true,
        "schema": {
          "type": "string"
        }
      },
      "version": {
        "example": 2,
        "in": "path",
        "name": "version",
        "required": true,
        "schema": {
          "minimum": 1,
          "type": "integer"
        }
      }
    },
    "responses": {
      "BadRequest": {
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        },
        "description": "Invalid request. A saved-data write whose JSON holds a NUL character (\\u0000), half of a surrogate pair or bytes that are not UTF-8 is code invalid_json."
      },
      "Conflict": {
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        },
        "description": "Conflict: the code says which (site_exists, domain_taken, append_only, custom_domain_required, not_an_object)"
      },
      "NotFound": {
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        },
        "description": "Not found"
      },
      "OriginMismatch": {
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        },
        "description": "Origin/Referer is not one of the site's own addresses (code origin_not_allowed). An agent or script sends the site owner's X-API-Key and no Origin or Referer, which needs no page."
      },
      "OriginMismatchOrSiteLocked": {
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        },
        "description": "Origin/Referer is not one of the site's own addresses (code origin_not_allowed; an agent or script sends the site owner's X-API-Key and no Origin or Referer), or the site has a passcode and the request carries no unlock for this host, nor the owner's or admin's key (code site_locked)."
      },
      "TooLarge": {
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        },
        "description": "Payload too large (on saved data, code item_too_large; on a deploy: site_too_large (per-upload limit), site_total_too_large (whole website footprint for sites created from SITE_TOTAL_CAP_FROM), or account_storage_full (all account website files including Recently deleted). Totals count the separate live copy and kept versions after pruning; backend storage and saved data are excluded. The error names the limit and current size for total caps, and hint gives photo-compression and file-cleanup tips)"
      },
      "TooManyRequests": {
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        },
        "description": "Rate limited (code rate_limited). Saved-data reads allow 30 a second per address with a burst of 60 (SAVED_DATA_READ_PER_SEC, SAVED_DATA_READ_BURST)."
      },
      "Unauthorized": {
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        },
        "description": "Missing or invalid API key — send the X-API-Key header, not Authorization Bearer. The body's code is missing_api_key, wrong_auth_header, invalid_api_key, key_expired or key_expired_idle (create a new key from the Keys panel)."
      }
    },
    "schemas": {
      "APIKey": {
        "properties": {
          "created_at": {
            "format": "date-time",
            "type": "string"
          },
          "current": {
            "description": "This is the key the request was made with",
            "type": "boolean"
          },
          "expired": {
            "description": "Present once the key has stopped working: past expires_at (expired) or unused too long (idle)",
            "enum": [
              "expired",
              "idle"
            ],
            "type": "string"
          },
          "expires_at": {
            "description": "The fixed expiry chosen when it was minted; null for none",
            "format": "date-time",
            "nullable": true,
            "type": "string"
          },
          "id": {
            "format": "uuid",
            "type": "string"
          },
          "idle_expires_at": {
            "description": "When it stops working unless used before then; null when idle expiry is off",
            "format": "date-time",
            "nullable": true,
            "type": "string"
          },
          "last4": {
            "description": "Last 4 characters of the key",
            "to recognise it. Absent on older keys.": null,
            "type": "string"
          },
          "last_used_at": {
            "format": "date-time",
            "nullable": true,
            "type": "string"
          },
          "name": {
            "description": "Where it came from (dashboard sign-in, agent sign-in, event account, replacement key) or the name typed when it was created. Absent on keys issued before names existed.",
            "type": "string"
          },
          "scope": {
            "description": "full, or deploy (create, update, roll back and list sites and make preview links only)",
            "enum": [
              "full",
              "deploy"
            ],
            "type": "string"
          }
        },
        "required": [
          "id",
          "created_at",
          "last_used_at",
          "current"
        ],
        "type": "object"
      },
      "AddressFamily": {
        "properties": {
          "bound_at": {
            "format": "date-time",
            "type": "string"
          },
          "canonical": {
            "description": "Whether a family address is each matching site's main address (unless it has a domain of its own)",
            "type": "boolean"
          },
          "cert_name": {
            "description": "Admin view only: the operator's certificate lineage",
            "type": "string"
          },
          "certificate": {
            "properties": {
              "expires_at": {
                "format": "date-time",
                "type": "string"
              },
              "mode": {
                "enum": [
                  "wildcard"
                ],
                "type": "string"
              },
              "note": {
                "type": "string"
              },
              "status": {
                "description": "waiting_for_operator (the operator has not set up the wildcard certificate yet), pending (the family is not verified yet), issuing (being put in service), live, failed (note says why)",
                "enum": [
                  "waiting_for_operator",
                  "pending",
                  "issuing",
                  "live",
                  "failed"
                ],
                "type": "string"
              }
            },
            "required": [
              "mode",
              "status"
            ],
            "type": "object"
          },
          "dns": {
            "description": "The wildcard record to add at the registrar",
            "properties": {
              "host": {
                "example": "*.trips.example.com",
                "type": "string"
              },
              "type": {
                "example": "CNAME",
                "type": "string"
              },
              "value": {
                "example": "cname.simple-host.app",
                "type": "string"
              }
            },
            "type": "object"
          },
          "dns_a": {
            "description": "The A-record alternative to dns, where the server hands one out",
            "properties": {
              "host": {
                "example": "*.trips.example.com",
                "type": "string"
              },
              "type": {
                "example": "A",
                "type": "string"
              },
              "value": {
                "example": "203.0.113.7",
                "type": "string"
              }
            },
            "type": "object"
          },
          "dns_txt": {
            "description": "The ownership record: TXT _simple-host.\u003csuffix\u003e holding the family's token. Keep it in place. Absent when the operator exempted the family from it.",
            "properties": {
              "host": {
                "example": "_simple-host.trips.example.com",
                "type": "string"
              },
              "type": {
                "example": "TXT",
                "type": "string"
              },
              "value": {
                "example": "sh-0123456789abcdef0123456789abcdef",
                "type": "string"
              }
            },
            "type": "object"
          },
          "example_url": {
            "description": "One matching site's address under the family",
            "example": "https://lisbon.trips.example.com/",
            "type": "string"
          },
          "expires_at": {
            "description": "Pending only: when it is dropped if its records are still not seen",
            "format": "date-time",
            "type": "string"
          },
          "failing_since": {
            "format": "date-time",
            "type": "string"
          },
          "family": {
            "example": "*.trips.example.com",
            "type": "string"
          },
          "id": {
            "format": "uuid",
            "type": "string"
          },
          "last_error": {
            "description": "What the last check found",
            "type": "string",
            "when it failed": null
          },
          "live": {
            "description": "Its addresses are answered now: verified and its certificate live",
            "type": "boolean"
          },
          "owner": {
            "description": "Admin view only: the owner's email",
            "type": "string"
          },
          "owner_id": {
            "description": "Admin view only: the owner's account id",
            "type": "string"
          },
          "proof_exempt": {
            "description": "Present and true when the operator verified it without the TXT record",
            "type": "boolean"
          },
          "rank": {
            "type": "integer"
          },
          "release_at": {
            "description": "When a failing family is disconnected (after its owner was emailed)",
            "format": "date-time",
            "type": "string"
          },
          "site_prefix": {
            "example": "",
            "type": "string"
          },
          "sites": {
            "description": "Only with ?sites=1: every matching site and its address",
            "items": {
              "properties": {
                "address": {
                  "example": "https://lisbon.trips.example.com/",
                  "type": "string"
                },
                "lives_at": {
                  "description": "The site has a domain of its own; this address redirects there",
                  "type": "string"
                },
                "main": {
                  "description": "This is the site's main address",
                  "type": "boolean"
                },
                "site": {
                  "example": "lisbon",
                  "type": "string"
                }
              },
              "required": [
                "site",
                "address",
                "main"
              ],
              "type": "object"
            },
            "type": "array"
          },
          "status": {
            "description": "pending (waiting for the DNS records), active (verified), failing (verified, but its checks fail now)",
            "enum": [
              "pending",
              "active",
              "failing"
            ],
            "type": "string"
          },
          "suffix": {
            "example": "trips.example.com",
            "type": "string"
          },
          "verified_at": {
            "format": "date-time",
            "type": "string"
          }
        },
        "required": [
          "id",
          "family",
          "suffix",
          "site_prefix",
          "rank",
          "canonical",
          "status",
          "live",
          "bound_at",
          "certificate"
        ],
        "type": "object"
      },
      "AddressFamilyRequest": {
        "description": "`suffix` or `family` names it: `*.trips.example.com` or `trips.example.com`.",
        "properties": {
          "canonical": {
            "default": true,
            "description": "A family address is the site's main address (unless the site has a domain of its own); false keeps the site's own address as the main one.",
            "type": "boolean"
          },
          "cert_mode": {
            "default": "wildcard",
            "description": "wildcard: the operator's wildcard certificate. per_host is not available yet (400 cert_mode_unavailable).",
            "enum": [
              "wildcard",
              "per_host"
            ],
            "type": "string"
          },
          "family": {
            "description": "Same as suffix",
            "type": "string"
          },
          "rank": {
            "default": 0,
            "description": "Tie-break between families with the same site_prefix: the lowest rank is the main address.",
            "maximum": 1000,
            "minimum": 0,
            "type": "integer"
          },
          "site_prefix": {
            "description": "Sites named \u003csite_prefix\u003e\u003clabel\u003e answer at \u003clabel\u003e.\u003csuffix\u003e. Lowercase letters, digits and hyphens, not at the start, up to 40 characters. Default none.",
            "example": "voucher-",
            "type": "string"
          },
          "suffix": {
            "example": "*.trips.example.com",
            "type": "string"
          }
        },
        "type": "object"
      },
      "AddressState": {
        "description": "The account's own site address (https://\u003csite\u003e.\u003chandle\u003e.simple-host.app/) and its certificate. On GET /v1/me always (on simple-host.app, once the account has a handle); on a site (address_state) only while that site is still at its interim address https://\u003chandle\u003e.simple-host.app/\u003csite\u003e/. When the address switches, old links redirect, but visitors' sign-ins and what their browser kept for the site start fresh.",
        "properties": {
          "address": {
            "description": "The own address: on /v1/me the pattern https://\u003csite\u003e.\u003chandle\u003e.simple-host.app/, on a site that site's address",
            "type": "string"
          },
          "interim_address": {
            "description": "Where sites are until then (waiting or failing)",
            "type": "string"
          },
          "note": {
            "description": "A plain sentence to show the person (waiting or failing)",
            "type": "string"
          },
          "ready_by": {
            "description": "Rough estimate (waiting or failing)",
            "format": "date-time",
            "type": "string"
          },
          "ready_in_hours": {
            "description": "The same, in whole hours, at least 1",
            "type": "integer"
          },
          "state": {
            "description": "ready; waiting (the certificate is queued behind the weekly and daily limits); failing (it could not be issued yet and is retried automatically)",
            "enum": [
              "ready",
              "waiting",
              "failing"
            ],
            "type": "string"
          }
        },
        "type": "object"
      },
      "AuthResponse": {
        "properties": {
          "api_key": {
            "description": "A new key for this sign-in, starting shk_ (keys issued before 2026-09-27 are bare hex and keep working). Earlier keys keep working until revoked (DELETE /v1/me/keys/{id}) or rotated. Stored only as a hash, so it is shown only here.",
            "type": "string"
          },
          "created": {
            "description": "true if this verify created the account",
            "type": "boolean"
          },
          "id": {
            "format": "uuid",
            "type": "string"
          },
          "is_admin": {
            "type": "boolean"
          },
          "username": {
            "type": "string"
          }
        },
        "type": "object"
      },
      "DeletedResponse": {
        "properties": {
          "deleted": {
            "type": "boolean"
          }
        },
        "required": [
          "deleted"
        ],
        "type": "object"
      },
      "ErrorResponse": {
        "properties": {
          "code": {
            "description": "A stable snake_case name for the refusal, present where one status has more than one meaning. site_exists (409: this account already has a site of that name), domain_taken (409: the address belongs to another site or person), invalid_name (400: not 1-63 lowercase letters, digits and hyphens), name_reserved (400: reserved by the platform), invalid_domain (400: not a domain that can be connected), site_quota_reached (403), append_only (409: public list items cannot be edited; the owner may still delete them), custom_domain_required (409: the site needs its own address first), not_an_object (409: the saved value is not a JSON object), private_visitor_only and private_needs_own_domain (403), use_custom_domain and visitor_auth_required (401), missing_api_key, wrong_auth_header (sent Authorization instead of X-API-Key), invalid_api_key (unknown, revoked or signed-out key), key_expired (past the expiry chosen when it was minted) and key_expired_idle (unused too long, KEY_IDLE_EXPIRY_DAYS) (401), deploy_only_key (403: a deploy-only key on a route it may not call), csrf_required (403), recently_deleted (409: the name belongs to a site in Recently deleted), site_not_deleted (409: restore of a live site), confirm_required (400), not_found (404, private collections; also a PUT deploy to a site the account does not have), site_not_found (404: no site of that name on this account, on versions, rollback and site settings), export_link_invalid (404: the download link expired or is not valid), site_suspended (403: the operator has taken the site down; changes and public reads of its data are refused until it is restored, `reason` says why), account_suspended (403: the operator has suspended this account; its keys, connected apps and sign-in are refused, `reason` says why), site_offline (403: the owner has taken the site offline; visitor saves are refused until it is back online, the owner's key still writes), preview_read_only (403: a save made from a page opened as a version preview), preview_unavailable (409: the site has no address of its own to preview on), site_locked (403: the site has a passcode and the request carries no unlock for this host, nor the owner's or admin's key), invalid_passcode (400), passcodes_not_enabled and passcode_needs_own_address (409: a passcode cannot be set on this server), no_passcode (409), invalid_request (400), use_address_family (400: a *.\u003cdomain\u003e sent to a site's domain route), no_address_family (404), invalid_site_prefix, invalid_rank, invalid_cert_mode, cert_mode_unavailable (400), address_family_limit and address_families_unavailable (409), signup_unavailable_region (403: this server does not allow sign-in or sign-up from the signer's region, new account or existing; an API key is unaffected), legacy_storage_closed (410: this site was created on or after LEGACY_STORAGE_CUTOFF; use storage resources), legacy_storage_removed (410: Simple Hack has no old saved-data routes). New codes may be added.",
            "example": "domain_taken",
            "type": "string"
          },
          "error": {
            "description": "What went wrong, in plain words. The wording may change; branch on `code`.",
            "type": "string"
          },
          "hint": {
            "description": "How to fix the request, in plain words, where there is a concrete next step. A 404 for a site the account does not have names the call that creates it (a deploy: add ?create=1 to the PUT, or POST) or points at GET /v1/sites (everything else). Present only on some errors; the wording may change.",
            "example": "No site named blog on your account. To create it, add ?create=1 (PUT /v1/sites/blog/files?create=1) or POST /v1/sites/blog/files.",
            "type": "string"
          },
          "reason": {
            "description": "On 401 invalid_code from email sign-in, which failure it is: code_wrong, code_used_up, code_expired, or link_other_browser.",
            "type": "string"
          },
          "storage": {
            "description": "On 410 legacy_storage_closed, the storage-resources path for this site (`/v1/sites/{site}/storage/resources`).",
            "type": "string"
          },
          "tries_left": {
            "description": "Remaining guesses on a code_wrong sign-in answer.",
            "type": "integer"
          }
        },
        "required": [
          "error"
        ],
        "type": "object"
      },
      "FilesBody": {
        "example": {
          "files": {
            "css/style.css": "body{font-family:sans-serif}",
            "index.html": "\u003c!DOCTYPE html\u003e\u003ch1\u003eHello\u003c/h1\u003e"
          },
          "files_base64": {
            "assets/logo.png": "iVBORw0KGgoAAAANSUhEUg..."
          }
        },
        "properties": {
          "files": {
            "additionalProperties": {
              "type": "string"
            },
            "description": "relative path -\u003e file contents (text). index.html required unless PUT uses keep=1.",
            "type": "object"
          },
          "files_base64": {
            "additionalProperties": {
              "format": "byte",
              "type": "string"
            },
            "description": "relative path -\u003e base64-encoded binary contents. A path cannot also appear in `files`.",
            "type": "object"
          },
          "remove_files": {
            "description": "Paths to drop from the live version; only with PUT keep=1.",
            "items": {
              "type": "string"
            },
            "type": "array"
          }
        },
        "required": [
          "files"
        ],
        "type": "object"
      },
      "Notification": {
        "description": "Owner-visible subscription. Never contains the signing secret or custom header value.",
        "properties": {
          "app_name": {
            "type": "string"
          },
          "auth_header_name": {
            "type": "string"
          },
          "created_at": {
            "format": "date-time",
            "type": "string"
          },
          "events": {
            "items": {
              "$ref": "#/components/schemas/NotificationEventName"
            },
            "type": "array"
          },
          "expires_at": {
            "format": "date-time",
            "nullable": true,
            "type": "string"
          },
          "failing_since": {
            "format": "date-time",
            "nullable": true,
            "type": "string"
          },
          "id": {
            "example": "sub_0123456789abcdef01234567",
            "type": "string"
          },
          "label": {
            "type": "string"
          },
          "last_attempt_at": {
            "format": "date-time",
            "nullable": true,
            "type": "string"
          },
          "last_result": {
            "$ref": "#/components/schemas/NotificationResult"
          },
          "last_success_at": {
            "format": "date-time",
            "nullable": true,
            "type": "string"
          },
          "site": {
            "type": "string"
          },
          "source": {
            "enum": [
              "mcp",
              "owner"
            ],
            "type": "string"
          },
          "state": {
            "enum": [
              "active",
              "paused",
              "suspended",
              "pending",
              "expired"
            ],
            "type": "string"
          },
          "storage": {
            "description": "Resource filter; empty matches all storage on the site.",
            "type": "string"
          },
          "url": {
            "format": "uri",
            "type": "string"
          }
        },
        "type": "object"
      },
      "NotificationAuthHeader": {
        "properties": {
          "name": {
            "description": "Authorization or X- followed by 1 to 62 letters, digits, or hyphens (case-insensitive). X-Forwarded-*, X-Real-IP, X-MCP-*, X-Client-IP, and X-Originating-IP are reserved.",
            "example": "Authorization",
            "pattern": "^([Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]|[Xx]-[A-Za-z0-9-]{1,62})$",
            "type": "string"
          },
          "value": {
            "description": "At most 1024 bytes; printable, with no CR or LF.",
            "example": "Bearer receiver-token",
            "maxLength": 1024,
            "type": "string",
            "writeOnly": true
          }
        },
        "required": [
          "name",
          "value"
        ],
        "type": "object"
      },
      "NotificationCreate": {
        "properties": {
          "auth_header": {
            "$ref": "#/components/schemas/NotificationAuthHeader"
          },
          "events": {
            "items": {
              "$ref": "#/components/schemas/NotificationEventName"
            },
            "minItems": 1,
            "type": "array"
          },
          "label": {
            "default": "",
            "type": "string"
          },
          "secret": {
            "$ref": "#/components/schemas/NotificationSecret"
          },
          "storage": {
            "default": "",
            "description": "Existing site storage resource; empty means all resources.",
            "type": "string"
          },
          "url": {
            "description": "HTTPS, at most 2048 bytes, no userinfo or fragment. Every resolved address must be public. Redirects are not followed.",
            "format": "uri",
            "maxLength": 2048,
            "type": "string"
          }
        },
        "required": [
          "url",
          "events"
        ],
        "type": "object"
      },
      "NotificationCreated": {
        "properties": {
          "notification": {
            "$ref": "#/components/schemas/Notification"
          },
          "secret": {
            "$ref": "#/components/schemas/NotificationSecret"
          }
        },
        "required": [
          "notification",
          "secret"
        ],
        "type": "object"
      },
      "NotificationDeleted": {
        "properties": {
          "deleted": {
            "enum": [
              true
            ],
            "type": "boolean"
          }
        },
        "required": [
          "deleted"
        ],
        "type": "object"
      },
      "NotificationDeliveries": {
        "properties": {
          "deliveries": {
            "items": {
              "$ref": "#/components/schemas/NotificationDelivery"
            },
            "maxItems": 200,
            "type": "array"
          }
        },
        "required": [
          "deliveries"
        ],
        "type": "object"
      },
      "NotificationDelivery": {
        "properties": {
          "attempts": {
            "minimum": 0,
            "type": "integer"
          },
          "created_at": {
            "format": "date-time",
            "type": "string"
          },
          "duration_ms": {
            "nullable": true,
            "type": "integer"
          },
          "event_id": {
            "type": "string"
          },
          "last_attempt_at": {
            "format": "date-time",
            "nullable": true,
            "type": "string"
          },
          "last_http": {
            "nullable": true,
            "type": "integer"
          },
          "name": {
            "description": "Storage event name. Test sends do not appear in delivery history.",
            "type": "string"
          },
          "result": {
            "$ref": "#/components/schemas/NotificationResult"
          },
          "state": {
            "enum": [
              "pending",
              "delivered",
              "failed",
              "skipped"
            ],
            "type": "string"
          }
        },
        "type": "object"
      },
      "NotificationError": {
        "properties": {
          "code": {
            "description": "Notification-specific errors. Authentication middleware can also return its usual credential errors.",
            "enum": [
              "invalid_url",
              "refused_address",
              "invalid_events",
              "invalid_secret",
              "invalid_header",
              "storage_not_found",
              "too_many_notifications",
              "notifications_not_enabled",
              "not_found",
              "not_offered",
              "client_secret"
            ],
            "type": "string"
          },
          "error": {
            "description": "Plain explanation.",
            "type": "string"
          }
        },
        "required": [
          "error",
          "code"
        ],
        "type": "object"
      },
      "NotificationEventName": {
        "enum": [
          "record.added",
          "record.changed",
          "file.uploaded"
        ],
        "type": "string"
      },
      "NotificationLimits": {
        "description": "Effective operator-configured limits. Defaults are 10 subscriptions/site, 60 events/minute, 5,000/day, and 7 days of history.",
        "properties": {
          "enabled": {
            "description": "Whether the server has PASSCODE_ENC_KEY and accepts new notifications.",
            "type": "boolean"
          },
          "history_days": {
            "example": 7,
            "type": "integer"
          },
          "per_day": {
            "example": 5000,
            "type": "integer"
          },
          "per_minute": {
            "example": 60,
            "type": "integer"
          },
          "per_site": {
            "example": 10,
            "type": "integer"
          }
        },
        "required": [
          "enabled",
          "per_site",
          "per_minute",
          "per_day",
          "history_days"
        ],
        "type": "object"
      },
      "NotificationList": {
        "properties": {
          "limits": {
            "$ref": "#/components/schemas/NotificationLimits"
          },
          "notifications": {
            "items": {
              "$ref": "#/components/schemas/Notification"
            },
            "type": "array"
          }
        },
        "required": [
          "notifications",
          "limits"
        ],
        "type": "object"
      },
      "NotificationPause": {
        "properties": {
          "paused": {
            "description": "true pauses; false resumes a paused or suspended subscription and makes held deliveries due now.",
            "type": "boolean"
          }
        },
        "required": [
          "paused"
        ],
        "type": "object"
      },
      "NotificationResult": {
        "description": "Empty before an attempt; ok for success; otherwise a failure category. Receiver response bodies are never stored or returned.",
        "enum": [
          "",
          "ok",
          "connection_refused",
          "secret_unreadable",
          "timeout",
          "tls_error",
          "http_4xx",
          "http_5xx",
          "redirect",
          "refused_address",
          "dns_error",
          "challenge_failed",
          "site_unavailable"
        ],
        "type": "string"
      },
      "NotificationRotatedSecret": {
        "properties": {
          "previous_valid_until": {
            "description": "Null when the previous secret cannot be read.",
            "format": "date-time",
            "nullable": true,
            "type": "string"
          },
          "secret": {
            "$ref": "#/components/schemas/NotificationSecret"
          }
        },
        "required": [
          "secret",
          "previous_valid_until"
        ],
        "type": "object"
      },
      "NotificationSecret": {
        "description": "whsec_ followed by standard base64 encoding of 24 to 64 random bytes. Returned only on creation or rotation; store in the receiver configuration.",
        "format": "password",
        "pattern": "^whsec_[A-Za-z0-9+/]+={0,2}$",
        "type": "string"
      },
      "NotificationTestResult": {
        "properties": {
          "delivered": {
            "type": "boolean"
          },
          "result": {
            "$ref": "#/components/schemas/NotificationResult"
          },
          "status": {
            "description": "Receiver HTTP status, or 0 when no response was received.",
            "type": "integer"
          }
        },
        "required": [
          "delivered",
          "status",
          "result"
        ],
        "type": "object"
      },
      "NotificationUpdated": {
        "properties": {
          "notification": {
            "$ref": "#/components/schemas/Notification"
          }
        },
        "required": [
          "notification"
        ],
        "type": "object"
      },
      "Site": {
        "properties": {
          "access": {
            "description": "Who can open the site: anyone, or specific (only the owner and its named viewers; GET .../access).",
            "enum": [
              "anyone",
              "specific"
            ],
            "type": "string"
          },
          "active_version": {
            "type": "integer"
          },
          "address_state": {
            "$ref": "#/components/schemas/AddressState"
          },
          "created_at": {
            "format": "date-time",
            "type": "string"
          },
          "custom_domain": {
            "description": "The site's free \u003cname\u003e.simple-host.app address or its own domain, when it has one",
            "type": "string"
          },
          "deployed_at": {
            "description": "When the newest version went live (site list only)",
            "format": "date-time",
            "type": "string"
          },
          "domain_certificate_status": {
            "description": "Site list only, for a domain not live yet: its certificate",
            "enum": [
              "pending",
              "issuing",
              "live",
              "failed"
            ],
            "type": "string"
          },
          "domain_dns": {
            "description": "Site list only, for an own domain that is not active: the DNS record that points it here, to add at the registrar",
            "properties": {
              "host": {
                "type": "string"
              },
              "type": {
                "example": "CNAME",
                "type": "string"
              },
              "value": {
                "type": "string"
              }
            },
            "type": "object"
          },
          "domain_dns_txt": {
            "description": "Site list only, for an own domain that is not active: the TXT ownership record to add next to domain_dns (_simple-host.\u003cdomain\u003e holding the site's token)",
            "properties": {
              "host": {
                "example": "_simple-host.www.example.com",
                "type": "string"
              },
              "type": {
                "example": "TXT",
                "type": "string"
              },
              "value": {
                "example": "sh-0123456789abcdef0123456789abcdef",
                "type": "string"
              }
            },
            "type": "object"
          },
          "domain_expires_at": {
            "description": "Site list only: when an unproven domain binding is released (24 h after binding; absent once DNS points here)",
            "format": "date-time",
            "type": "string"
          },
          "domain_last_error": {
            "description": "Site list only, for a domain that is not active: what the last check found",
            "type": "string"
          },
          "domain_partner": {
            "description": "The custom domain's www / bare partner, which forwards to it (see GET .../domain partner_domain)",
            "type": "string"
          },
          "domain_partner_dns": {
            "description": "The record that points the partner here too",
            "properties": {
              "host": {
                "type": "string"
              },
              "type": {
                "example": "CNAME",
                "type": "string"
              },
              "value": {
                "type": "string"
              }
            },
            "type": "object"
          },
          "domain_partner_note": {
            "description": "Why the partner is not set up, when it is not",
            "type": "string"
          },
          "domain_partner_status": {
            "enum": [
              "pending",
              "live",
              "not_set_up"
            ],
            "type": "string"
          },
          "domain_status": {
            "enum": [
              "pending",
              "active",
              "error"
            ],
            "type": "string"
          },
          "family_address": {
            "description": "The site's address under its account's most specific live address family (https://\u003clabel\u003e.\u003csuffix\u003e/): its main address unless it has a domain of its own. Absent when no family serves it. site_url stays the site's own address.",
            "example": "https://lisbon.trips.example.com/",
            "type": "string"
          },
          "family_addresses": {
            "description": "Every address family address the site answers at, the most specific first",
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "file_bytes": {
            "description": "Website file footprint including live copy and kept versions; populated in GET /v1/sites",
            "type": "integer"
          },
          "id": {
            "format": "uuid",
            "type": "string"
          },
          "idle_removal_at": {
            "description": "Site list only: the site had no visits by people and no new version for 90 days and its owner was emailed; it moves to Recently deleted at this time unless it is kept, visited or updated",
            "format": "date-time",
            "type": "string"
          },
          "keep": {
            "description": "Site list only: the owner marked the site Keep; it is never flagged as idle (PUT .../keep)",
            "type": "boolean"
          },
          "keep_versions": {
            "description": "How many versions of this site are kept, when its owner set it (PUT .../keep-versions): the newest N plus the live one. Absent = this server's setting",
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "offline": {
            "description": "Present and true when the owner has taken the site offline (PATCH /v1/sites/{sitename} {\"offline\": true}): every address shows \"This site is offline\" and visitor saves are refused. Nothing is deleted.",
            "type": "boolean"
          },
          "passcode_protected": {
            "description": "Present and true when the site has a passcode (PUT .../lock): every address shows \"This site is protected\" until a visitor enters it. The passcode itself is only in GET .../lock.",
            "type": "boolean"
          },
          "preview_expires_at": {
            "description": "When preview_url stops working.",
            "format": "date-time",
            "type": "string"
          },
          "preview_url": {
            "description": "Only on a deploy with publish=false: a link showing the stored version that works for anyone with it, for one hour.",
            "format": "uri",
            "type": "string"
          },
          "previous_domain": {
            "description": "Site list only, for a domain not live yet: the earlier address the site keeps answering at until it is",
            "type": "string"
          },
          "site_url": {
            "description": "The link to share: https://\u003csite\u003e.\u003chandle\u003e.simple-host.app/ (briefly https://\u003chandle\u003e.simple-host.app/\u003csite\u003e/ for a brand-new account), or the site's domain when it has one",
            "example": "https://my-cool-site.jane.simple-host.app/",
            "nullable": true,
            "type": "string"
          },
          "summary": {
            "description": "Compact content from the newest uploaded index.html; omitted when empty.",
            "properties": {
              "description": {
                "description": "Meta description (up to 300 bytes).",
                "type": "string"
              },
              "headings": {
                "description": "First six headings (up to 300 bytes).",
                "type": "string"
              },
              "text": {
                "description": "Visible body text (up to 300 bytes).",
                "type": "string"
              },
              "title": {
                "description": "Page title (up to 200 bytes).",
                "type": "string"
              }
            },
            "type": "object"
          },
          "suspended": {
            "description": "Present and true when the operator has taken the site down (on its own or with its owner's account): every address shows a \"taken down\" page and changes are refused until it is restored. Nothing is deleted.",
            "type": "boolean"
          },
          "suspended_reason": {
            "description": "The operator's one-line reason",
            "type": "string",
            "when suspended.": null
          },
          "unpublished_version": {
            "description": "Only on a deploy with publish=false: the version stored without going live.",
            "type": "integer"
          },
          "updated_at": {
            "format": "date-time",
            "type": "string"
          },
          "user_id": {
            "format": "uuid",
            "type": "string"
          },
          "visibility": {
            "enum": [
              "public",
              "unlisted"
            ],
            "type": "string"
          }
        },
        "type": "object"
      },
      "SiteAccess": {
        "description": "Who can open a site (named viewers)",
        "properties": {
          "access": {
            "enum": [
              "anyone",
              "specific"
            ],
            "type": "string"
          },
          "added": {
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "already_listed": {
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "note": {
            "type": "string"
          },
          "passcode_protected": {
            "type": "boolean"
          },
          "removed": {
            "type": "string"
          },
          "site": {
            "type": "string"
          },
          "viewer_limit": {
            "description": "SITE_VIEWERS_MAX",
            "type": "integer"
          },
          "viewers": {
            "items": {
              "properties": {
                "added_at": {
                  "format": "date-time",
                  "type": "string"
                },
                "email": {
                  "type": "string"
                },
                "signed_in_at": {
                  "description": "When they first signed in to this site. Absent if they have not signed in yet.",
                  "format": "date-time",
                  "type": "string"
                }
              },
              "type": "object"
            },
            "type": "array"
          }
        },
        "required": [
          "site",
          "access",
          "viewers",
          "viewer_limit",
          "passcode_protected"
        ],
        "type": "object"
      },
      "SitePasscode": {
        "properties": {
          "note": {
            "description": "What happened",
            "in plain words": null,
            "to relay to the person": null,
            "type": "string"
          },
          "passcode": {
            "description": "The passcode (owner only; GET and PUT .../lock). Absent when there is none, or when the server's key changed and it cannot be read.",
            "type": "string"
          },
          "passcode_protected": {
            "type": "boolean"
          },
          "passcode_set_at": {
            "format": "date-time",
            "type": "string"
          },
          "site": {
            "type": "string"
          }
        },
        "required": [
          "site",
          "passcode_protected"
        ],
        "type": "object"
      },
      "SiteWithNote": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Site"
          },
          {
            "properties": {
              "note": {
                "type": "string"
              }
            },
            "type": "object"
          }
        ]
      },
      "StorageDownloadLink": {
        "properties": {
          "expires_at": {
            "format": "date-time",
            "type": "string"
          },
          "url": {
            "type": "string"
          }
        },
        "required": [
          "url",
          "expires_at"
        ],
        "type": "object"
      },
      "StorageRowCreated": {
        "properties": {
          "changes": {
            "type": "integer"
          },
          "last_insert_id": {
            "format": "int64",
            "type": "integer"
          }
        },
        "required": [
          "changes",
          "last_insert_id"
        ],
        "type": "object"
      },
      "StorageSQL": {
        "properties": {
          "params": {
            "description": "Positional JSON scalar values",
            "items": {
              "nullable": true
            },
            "type": "array"
          },
          "sql": {
            "description": "One SQLite statement; use bound question-mark parameters.",
            "type": "string"
          }
        },
        "required": [
          "sql"
        ],
        "type": "object"
      },
      "TrafficCounts": {
        "properties": {
          "views": {
            "example": 61,
            "type": "integer"
          },
          "visitors": {
            "description": "Unique visitors — distinct hashed IPs over this bucket's window",
            "example": 24,
            "type": "integer"
          }
        },
        "type": "object"
      },
      "TrafficSplit": {
        "description": "One bucket of traffic, split by who was asking. `person` is the audience figure; `infra` is the site's own monitoring and is normally far larger than either of the other two. `unknown` is non-zero only for days before `classified_from`, which were recorded before traffic was classified and cannot be broken down after the fact — do not fold it into any of the other three.",
        "properties": {
          "bot": {
            "$ref": "#/components/schemas/TrafficCounts"
          },
          "infra": {
            "$ref": "#/components/schemas/TrafficCounts"
          },
          "person": {
            "$ref": "#/components/schemas/TrafficCounts"
          },
          "unknown": {
            "$ref": "#/components/schemas/TrafficCounts"
          }
        },
        "type": "object"
      }
    },
    "securitySchemes": {
      "apiKey": {
        "in": "header",
        "name": "X-API-Key",
        "type": "apiKey"
      },
      "connector": {
        "description": "Per-person OAuth 2.1 (authorization code; PKCE S256 required for every self-registered client). On Simple Host, a REST-audience access token sent as `Authorization: Bearer` acts as that person's X-API-Key. On hosted Simple Hack, new consent issues one personal `sites events` grant for all event roles. MCP `hack_select_team` stores a publishing team on that grant; each website call rechecks approved membership and uses a separate scoped team credential. Legacy `sites team:\u003cuuid\u003e` grants remain site-only. Event roles still control each operation. MCP-audience tokens work only on /mcp. Discovery at /.well-known/oauth-authorization-server publishes the scopes available on this instance; events is offered only in hosted hack mode.",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://simple-host.app/oauth/authorize",
            "refreshUrl": "https://simple-host.app/oauth/token",
            "scopes": {
              "events": "Act as the signed-in person across their Simple Hack events and roles",
              "sites": "Publish and manage your sites and their data"
            },
            "tokenUrl": "https://simple-host.app/oauth/token"
          }
        },
        "type": "oauth2"
      }
    }
  },
  "info": {
    "description": "Static website hosting with a light per-site backend. Deploy a site with one API call — inline JSON files (LLM-friendly) or a tar.gz/zip archive — and each site gets owner-declared KV, SQLite and file resources. Sign-in is a 6-digit email code or Google that returns an API key, sent as the `X-API-Key` header. Hosted-page sign-in uses the same account but a site-scoped session cookie on the site's own address — never an API key. Every site lives at its own address, `https://\u003csite\u003e.\u003chandle\u003e.simple-host.app/` (its own browser origin); `https://\u003chandle\u003e.simple-host.app/` lists the person's public sites. For a brand-new account a site may briefly live at `https://\u003chandle\u003e.simple-host.app/\u003csite\u003e/` until its certificate is issued: use the `site_url` the API returns, never a composed one. Old `\u003chandle\u003e.simple-host.app/\u003csite\u003e/` links redirect to the site's address, as do old `sites.simple-host.app/\u003chandle\u003e/\u003csite\u003e/` ones. Visitors sign in with Google or an emailed code on the site's own address (a sign-in covers that site only). Owner-declared KV, SQLite and file resources use independent read/write policies including own reads and add-only writes; anonymous writes require an explicit write=anyone choice. Agents act as the owner with the API key (or connector) through `https://simple-host.app/v1/...`. Home-page, bio and showcase pin/order settings also work on small-box path installs: the public person page opens the selected site's existing URL. Hosted person hosts serve it at their root.  Whole-space custom domains have not shipped.\nResponses passing through an IP token-bucket limiter include RateLimit, RateLimit-Policy, RateLimit-Limit (capacity), RateLimit-Remaining (whole tokens after this request), and RateLimit-Reset (seconds rounded up until one token is available, otherwise zero). RateLimit-Policy names \"token-bucket\", q is capacity, w is the empty-to-full refill duration rounded up in seconds, and refill is the exact tokens-per-second rate. This is continuous refill, not a fixed window. A bucket rejection is 429 with integer Retry-After seconds until the next token.\nOlder saved-data endpoints still work for sites that already use them; new sites use storage resources.",
    "title": "Simple Host API",
    "version": "2.0.0"
  },
  "openapi": "3.0.3",
  "paths": {
    "/auth.md": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "description": "OAuth and API-key sign-in documentation"
          }
        },
        "security": [],
        "summary": "Read the authentication reference",
        "tags": [
          "Auth"
        ]
      }
    },
    "/healthz": {
      "get": {
        "operationId": "getHealthz",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "status": {
                      "example": "ok",
                      "type": "string"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "Alive"
          }
        },
        "summary": "Liveness probe",
        "tags": [
          "Health"
        ]
      }
    },
    "/import/{token}": {
      "get": {
        "description": "Does not consume the link. No-store and no-referrer; token authority, no cookies or API key on the page.",
        "operationId": "getImportPage",
        "parameters": [
          {
            "in": "path",
            "name": "token",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "description": "Shared-theme HTML upload page"
          },
          "404": {
            "already used or unknown; ask the chat for a new one": null,
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Link expired"
          }
        },
        "security": [],
        "summary": "Open a one-time WordPress import page",
        "tags": [
          "Deploy"
        ]
      }
    },
    "/readyz": {
      "get": {
        "operationId": "getReadyz",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "status": {
                      "example": "ok",
                      "type": "string"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "Ready"
          },
          "503": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Not ready"
          }
        },
        "summary": "Readiness probe (DB connected)",
        "tags": [
          "Health"
        ]
      }
    },
    "/v1/analytics/sites": {
      "get": {
        "description": "One call returning the class split for all of the caller's sites, ordered by `person.views` descending — the \"which of my sites are actually being read\" view. Exists so a dashboard can sort by traffic without one request per site.\n\nNot under `/v1/sites/...` because that would collide with a site named \"analytics\". Pass `all=1` (admins only) to widen the scope to every site on the instance; non-admins get 403 rather than a silently narrowed list.",
        "operationId": "getAnalyticsSites",
        "parameters": [
          {
            "description": "Number of UTC days to include (default 30, clamp 1..365)",
            "in": "query",
            "name": "days",
            "required": false,
            "schema": {
              "default": 30,
              "maximum": 365,
              "minimum": 1,
              "type": "integer"
            }
          },
          {
            "description": "Admins only — include every site on the instance.",
            "in": "query",
            "name": "all",
            "required": false,
            "schema": {
              "enum": [
                "1"
              ],
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "range_days": {
                      "example": 30,
                      "type": "integer"
                    },
                    "sites": {
                      "items": {
                        "allOf": [
                          {
                            "properties": {
                              "name": {
                                "example": "eb2-wait",
                                "type": "string"
                              }
                            },
                            "type": "object"
                          },
                          {
                            "$ref": "#/components/schemas/TrafficSplit"
                          }
                        ]
                      },
                      "type": "array"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "Per-site totals, highest `person.views` first."
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "`all=1` requested by a non-admin"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Per-site traffic totals for every site you own",
        "tags": [
          "Analytics"
        ]
      }
    },
    "/v1/auth": {
      "post": {
        "description": "For Simple Host account owners and their agents, never for a site's visitors. Answers only on the app's own address: on a site's address, a person's address, a connected domain, or from a page's origin it is 403 account_auth_unavailable, and no code is sent. A page signs its visitors in with POST /v1/sites/{sitename}/visitor/auth through auth.js instead. Emails a 6-digit code (and a magic link) to the address. No user row is created here — the account is created lazily on the first successful verify, so this can't be used to enumerate users.",
        "operationId": "postAuth",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "email": {
                    "example": "you@example.com",
                    "format": "email",
                    "type": "string"
                  },
                  "nonce_hash": {
                    "description": "Browser pages only. base64url SHA-256 of a random nonce the page keeps. With it the email also carries a sign-in link, redeemable only together with that nonce (so only in that browser). Without it the email carries the code alone.",
                    "type": "string"
                  }
                },
                "required": [
                  "email"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "202": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "email": {
                      "type": "string"
                    },
                    "expires_in_seconds": {
                      "example": 900,
                      "type": "integer"
                    },
                    "message": {
                      "example": "We sent a 6-digit code to you@example.com.",
                      "type": "string"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "Code sent"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ErrorResponse"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "account_auth_unavailable",
                            "email_signin_off"
                          ],
                          "type": "string"
                        }
                      },
                      "type": "object"
                    }
                  ]
                }
              }
            },
            "description": "account_auth_unavailable: account sign-in answers only on the app's own address; a page's visitors use visitor sign-in (auth.js SH.mount). Or email_signin_off (EMAIL_SIGNIN=off): sign in with the company account button instead."
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "summary": "Request an account sign-in code (account owners only)",
        "tags": [
          "Auth"
        ]
      }
    },
    "/v1/auth/oauth/providers": {
      "get": {
        "description": "Public discovery for pages and the dashboard: auth.js reads it from a site to offer Google; it is not account sign-in and sets nothing. Google, GitHub and a company's own identity provider (oidc). Returns the enabled provider names so pages never hardcode a provider. Empty when sign-in is not configured. Sign-in creates or links a `users` row; a hosted-page session still does not disclose an API key.",
        "operationId": "getOAuthProviders",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "email_enabled": {
                      "description": "Whether email-code sign-in is configured and EMAIL_SIGNIN is not off",
                      "type": "boolean"
                    },
                    "oidc_label": {
                      "description": "The company sign-in button's name (OIDC_LABEL). Present only when oidc is in providers.",
                      "example": "your company account",
                      "type": "string"
                    },
                    "providers": {
                      "example": [
                        "google"
                      ],
                      "items": {
                        "enum": [
                          "google",
                          "github",
                          "oidc"
                        ],
                        "type": "string"
                      },
                      "type": "array"
                    }
                  },
                  "required": [
                    "providers",
                    "email_enabled"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Enabled providers"
          }
        },
        "summary": "List enabled sign-in methods (public discovery)",
        "tags": [
          "Auth"
        ]
      }
    },
    "/v1/auth/oauth/{provider}": {
      "get": {
        "description": "Account sign-in starts only on the app's own address. On any other host (a site's address, a connected domain) the same path answers 302 to GET /v1/visitor/oauth/{provider} on that host with the same return_to, so a hand-written link still reaches visitor sign-in and never an account key; from a page's origin on the apex it is 403 account_auth_unavailable. A page offers Google to its visitors with auth.js SH.signIn(), which starts at GET /v1/visitor/oauth/{provider} on the site's own host. Apex start. Query `return_to` is required and must be an absolute URL. Two shapes: the dashboard origin `/` (owner purpose — callback hands off via `/?token=`; success 302s to the provider with PKCE S256) or a site's own address — a site host `https://\u003csite\u003e.\u003chandle\u003e.simple-host.app/...` (or, while that person's certificate is pending, the person address `https://\u003chandle\u003e.simple-host.app/\u003csite\u003e/...`), a claimed name or a DNS-proven bound domain (site purpose). A site-purpose start is not begun here: it 302s to `GET /v1/visitor/oauth/{provider}` on that site's own host, which binds the sign-in to the browser. Unknown or disabled provider is 404. No cookie is set here. The Legacy path-address (`sites.\u003cdomain\u003e/\u003chandle\u003e/\u003csite\u003e/`) return_to URLs are rejected with 400 invalid return_to, as are apex URLs outside the dashboard. The caller cannot set `purpose`; it is derived from `return_to`.",
        "operationId": "startOAuth",
        "parameters": [
          {
            "in": "path",
            "name": "provider",
            "required": true,
            "schema": {
              "enum": [
                "google",
                "github",
                "oidc"
              ],
              "type": "string"
            }
          },
          {
            "description": "Absolute URL of the page to return to after sign-in",
            "in": "query",
            "name": "return_to",
            "required": true,
            "schema": {
              "format": "uri",
              "type": "string"
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Redirect to the identity provider",
            "headers": {
              "Location": {
                "schema": {
                  "format": "uri",
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "content": {
              "application/json": {
                "example": {
                  "error": "invalid return_to"
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Invalid or missing return_to"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "account_auth_unavailable: on the app's address but from a page's origin."
          },
          "404": {
            "content": {
              "application/json": {
                "example": {
                  "error": "not found"
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Unknown or disabled provider"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "content": {
              "application/json": {
                "example": {
                  "code": "oidc_unavailable",
                  "error": "company sign-in is temporarily unavailable, try again shortly"
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "oidc_unavailable: company sign-in's identity provider could not be reached yet (lazy discovery); retry shortly."
          }
        },
        "summary": "Start account Google sign-in (account owners; a site sign-in is forwarded)",
        "tags": [
          "Auth"
        ]
      }
    },
    "/v1/auth/oauth/{provider}/callback": {
      "get": {
        "description": "Answers only on the app's own address (403 account_auth_unavailable elsewhere). Provider redirect URI on the apex. Consumes the one-time `state`. Identifies via userinfo, then `resolveUser` (link or create a `users` row on a verified email; refuse missing/unverified email). Site purpose 302s to `https://\u003creturn-host\u003e/v1/visitor/establish?once=…`. Owner purpose 302s to `{PUBLIC_BASE_URL}/?token=…` for the dashboard to redeem via `POST /v1/auth/verify`. Neither redirect contains an API key. Failures are a short no-store HTML page on the apex with no Location and no cookie.",
        "operationId": "oauthCallback",
        "parameters": [
          {
            "in": "path",
            "name": "provider",
            "required": true,
            "schema": {
              "enum": [
                "google",
                "github",
                "oidc"
              ],
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "code",
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "state",
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "error",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Site purpose: redirect to /v1/visitor/establish on the stored host. Owner purpose: redirect to /?token= on PUBLIC_BASE_URL.",
            "headers": {
              "Location": {
                "schema": {
                  "format": "uri",
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "description": "Bad, replayed, or expired state, or provider error"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              },
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "description": "account_auth_unavailable (JSON): not on the app's own address. For oidc only, also a business-rule refusal (unverified email, domain not on ALLOWED_EMAIL_DOMAINS, Google hd not allowed): a themed no-store HTML page naming the reason in plain words, no internals."
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Unknown or disabled provider"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "502": {
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "description": "Token or userinfo failure"
          }
        },
        "summary": "OAuth callback (apex only)",
        "tags": [
          "Auth"
        ]
      }
    },
    "/v1/auth/verify": {
      "post": {
        "description": "For Simple Host account owners and their agents, never for a site's visitors. Answers only on the app's own address (403 account_auth_unavailable elsewhere), so no site page can mint an account key. A page signs its visitors in with POST /v1/sites/{sitename}/visitor/auth/verify through auth.js, which sets a cookie for that one site and never returns a key. Exchange `{email, code}` (CLI/agent) or a magic-link `{token}` (browser) for an API key. The first successful email-code verify creates the account. Dashboard Google also redeems through this endpoint (`/?token=\u0026cn=`). A link token is redeemed only with the `nonce` whose hash it was issued with, so a sign-in link works only in the browser that asked for it. Visitor-issued codes are not accepted here. The code expires in 15 minutes and allows 3 attempts.",
        "operationId": "postAuthVerify",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "choose_handle": {
                    "description": "When this sign-in would create a new account: create nothing yet and answer 409 with code choose_handle and a suggested_handle, without using up the code. Ask the person which address they want, then verify again with the same code and handle. Ignored for an existing account.",
                    "type": "boolean"
                  },
                  "code": {
                    "example": "123456",
                    "type": "string"
                  },
                  "email": {
                    "format": "email",
                    "type": "string"
                  },
                  "handle": {
                    "description": "The address (\u003chandle\u003e.simple-host.app) a NEW account takes; ignored for an existing account. Refused as 400 invalid_handle, 409 handle_reserved or 409 handle_taken without using up the code, so another can be tried. It can be changed later (PATCH /v1/me), not more than once in 30 days after publishing. Without handle or choose_handle a new account is given one.",
                    "pattern": "^[a-z0-9-]{1,39}$",
                    "type": "string"
                  },
                  "name": {
                    "description": "Optional label for the key this sign-in issues, shown in the Keys list (GET /v1/me/keys). Defaults to \"dashboard sign-in\" for a link or Google, \"agent sign-in\" for a typed code.",
                    "maxLength": 60,
                    "type": "string"
                  },
                  "nonce": {
                    "description": "required with token — the nonce whose hash was sent when the link was requested",
                    "type": "string"
                  },
                  "token": {
                    "description": "magic-link token (alternative to email+code)",
                    "type": "string"
                  }
                },
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthResponse"
                }
              }
            },
            "description": "Authenticated"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "content": {
              "application/json": {
                "example": {
                  "code": "invalid_code",
                  "error": "That code is not right. 2 tries left.",
                  "reason": "code_wrong",
                  "tries_left": 2
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Invalid code. `code` stays invalid_code. `reason` is code_wrong, code_used_up, code_expired, or link_other_browser; `tries_left` is set on code_wrong."
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ErrorResponse"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "account_auth_unavailable",
                            "email_signin_off"
                          ],
                          "type": "string"
                        }
                      },
                      "type": "object"
                    }
                  ]
                }
              }
            },
            "description": "account_auth_unavailable: account sign-in answers only on the app's own address; a page's visitors use visitor sign-in (auth.js SH.mount). Or email_signin_off (EMAIL_SIGNIN=off), for the email+code shape only — a magic-link token (from the company sign-in or Google flow) still redeems normally."
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "address": {
                      "example": "rose.simple-host.app",
                      "type": "string"
                    },
                    "code": {
                      "enum": [
                        "choose_handle",
                        "handle_taken",
                        "handle_reserved"
                      ],
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "suggested_handle": {
                      "type": "string"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "code choose_handle (a new account: choose its address first; carries suggested_handle and address), or handle_taken / handle_reserved (the handle sent cannot be had; error names the address). The code is not used up."
          }
        },
        "summary": "Verify the account code and get an API key (account owners only)",
        "tags": [
          "Auth"
        ]
      }
    },
    "/v1/export": {
      "get": {
        "description": "No API key: the signed `token` is the whole authorization, for one site, for 10 minutes. Streams the archive described under `export.tar.gz`. An expired, altered or unknown token, or one for a site since deleted or moved to another account, answers 404 with code `export_link_invalid`.",
        "operationId": "downloadExport",
        "parameters": [
          {
            "in": "query",
            "name": "token",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/gzip": {
                "schema": {
                  "format": "binary",
                  "type": "string"
                }
              }
            },
            "description": "A gzipped tar archive"
          },
          "404": {
            "content": {
              "application/json": {
                "example": {
                  "code": "export_link_invalid",
                  "error": "this download link is not valid or has expired"
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "The link is not valid or has expired"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "security": [],
        "summary": "Download a site's export through a link from export-link",
        "tags": [
          "Sites"
        ]
      }
    },
    "/v1/handles/check": {
      "get": {
        "description": "Whether \u003chandle\u003e.simple-host.app can be taken as an account's address: at sign-up (POST /v1/auth/verify with handle) or as a change (PATCH /v1/me). Handles, old handles that still redirect, free \u003cname\u003e.simple-host.app addresses claimed by sites, other accounts' live site names (an unclaimed \u003cname\u003e.simple-host.app still redirects to that site) and reserved names share one namespace. With X-API-Key the account's own handle counts as available. Rate limited per address (RATE_LIMIT_HANDLE_CHECK); no CORS, and a cross-site browser request gets 403. A claim still decides.",
        "operationId": "getHandleCheck",
        "parameters": [
          {
            "in": "query",
            "name": "handle",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "address": {
                      "example": "rose.simple-host.app",
                      "type": "string"
                    },
                    "available": {
                      "type": "boolean"
                    },
                    "code": {
                      "enum": [
                        "invalid_handle",
                        "handle_reserved",
                        "handle_taken"
                      ],
                      "type": "string"
                    },
                    "error": {
                      "example": "That address is taken: rose.simple-host.app is already in use. Try another.",
                      "type": "string"
                    },
                    "handle": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "handle",
                    "available",
                    "address"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "The verdict"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Cross-site browser request"
          },
          "429": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Too many checks from this address"
          }
        },
        "summary": "Is this address (handle) free?",
        "tags": [
          "Auth"
        ]
      }
    },
    "/v1/idle/keep": {
      "get": {
        "description": "Opened from the idle-site email; no sign-in, the token is the authorization. Shows a page with a \"Keep it online\" button and changes nothing (mail scanners follow links). 404 page when the link was used, replaced or has expired; 403 page while the site is taken down. Emailed links carry the token in the fragment (`#t=`), so it never reaches a server log: without `t` the GET returns a small page whose script posts the fragment's token back with `peek=1`, which shows this confirmation. Links sent before v0.7.1 carry `?t=` and keep working until they expire.",
        "operationId": "getIdleKeep",
        "parameters": [
          {
            "description": "The token from an older email",
            "in": "query",
            "name": "t",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "HTML confirmation page with the button"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "HTML page; the site is taken down"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "HTML page; the link was used or has expired"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "security": [],
        "summary": "\"Keep it\" link from an idle-site email (confirmation page)",
        "tags": [
          "Sites"
        ]
      },
      "post": {
        "description": "The confirmation page's button (with `peek=1`, only the confirmation page). The site stays up and its idle clock restarts; the link is then spent. HTML page.",
        "operationId": "postIdleKeep",
        "requestBody": {
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "properties": {
                  "peek": {
                    "enum": [
                      "1"
                    ],
                    "type": "string"
                  },
                  "t": {
                    "type": "string"
                  }
                },
                "required": [
                  "t"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "HTML page confirming the site stays up"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "HTML page; the site is taken down"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "HTML page; the link was used or has expired"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "security": [],
        "summary": "\"Keep it\" button",
        "tags": [
          "Sites"
        ]
      }
    },
    "/v1/idle/restore": {
      "get": {
        "description": "Shows a page with a \"Restore it\" button for a site the idle-site cleanup moved to Recently deleted, and changes nothing. No sign-in. Emailed links carry the token in the fragment (`#t=`), so it never reaches a server log: without `t` the GET returns a small page whose script posts the fragment's token back with `peek=1`, which shows this confirmation. Links sent before v0.7.1 carry `?t=` and keep working until they expire.",
        "operationId": "getIdleRestore",
        "parameters": [
          {
            "in": "query",
            "name": "t",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "HTML confirmation page with the button"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "HTML page; the site is taken down"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "HTML page; the link was used or has expired"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "security": [],
        "summary": "\"Restore it\" link from an idle-site removal email (confirmation page)",
        "tags": [
          "Sites"
        ]
      },
      "post": {
        "description": "Brings the site back exactly as it was (versions, saved data, names), with a fresh idle clock, while it is still in Recently deleted; the link is then spent (with `peek=1`, only the confirmation page). HTML page.",
        "operationId": "postIdleRestore",
        "requestBody": {
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "properties": {
                  "peek": {
                    "enum": [
                      "1"
                    ],
                    "type": "string"
                  },
                  "t": {
                    "type": "string"
                  }
                },
                "required": [
                  "t"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "HTML page confirming the site is back"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "HTML page; the site is taken down"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "HTML page; the link was used or has expired"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "security": [],
        "summary": "\"Restore it\" button",
        "tags": [
          "Sites"
        ]
      }
    },
    "/v1/imports/status/{import_id}": {
      "get": {
        "description": "Public by a random 128-bit ID; same-origin browsers only. Returns stages, counts and at most 10 plain warnings, with no owner, report, credentials or filenames. Finished imports expire after 24 hours; accounts retain at most 20 records. Usable links and receiving/queued/running imports retain their records. Expired unused links are swept. Status reads have a separate per-IP limiter (60 burst, one per second); retry after the Retry-After header on 429.",
        "operationId": "getWordPressImportStatus",
        "parameters": [
          {
            "in": "path",
            "name": "import_id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "done": {
                      "type": "integer"
                    },
                    "media": {
                      "type": "integer"
                    },
                    "message": {
                      "type": "string"
                    },
                    "pages": {
                      "type": "integer"
                    },
                    "posts": {
                      "type": "integer"
                    },
                    "site_url": {
                      "description": "Present after successful publication",
                      "type": "string"
                    },
                    "stage": {
                      "type": "string"
                    },
                    "state": {
                      "enum": [
                        "waiting",
                        "receiving",
                        "converting",
                        "publishing",
                        "done",
                        "failed"
                      ],
                      "type": "string"
                    },
                    "total": {
                      "type": "integer"
                    },
                    "warnings": {
                      "items": {
                        "type": "string"
                      },
                      "maxItems": 10,
                      "type": "array"
                    }
                  },
                  "required": [
                    "state",
                    "stage",
                    "done",
                    "total",
                    "message",
                    "pages",
                    "posts",
                    "media",
                    "warnings"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Current progress"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Too many status reads from this address",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before polling again",
                "schema": {
                  "type": "integer"
                }
              }
            }
          }
        },
        "security": [],
        "summary": "Read public progress for one import",
        "tags": [
          "Deploy"
        ]
      }
    },
    "/v1/imports/{token}": {
      "post": {
        "description": "Same-origin browser upload, authorized by the one-time token from create_import_link. Checks the creating connection's sign-in again. Streams up to 10 files to disk, sniffs XML, ZIP, TAR and gzip TAR, validates archive paths and byte limits, then converts and publishes asynchronously. Invalid files and a full queue leave the link usable. A reserved link returns 409. Publishing can replace the live version.",
        "operationId": "postWordPressImport",
        "parameters": [
          {
            "in": "path",
            "name": "token",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "multipart/form-data": {
              "schema": {
                "properties": {
                  "copy_photos": {
                    "default": true,
                    "description": "Fetch missing photos from the exported site",
                    "type": "boolean"
                  },
                  "files": {
                    "items": {
                      "format": "binary",
                      "type": "string"
                    },
                    "maxItems": 10,
                    "type": "array"
                  },
                  "theme_url": {
                    "description": "Optional old address to copy the look; must match the exported site",
                    "type": "string"
                  }
                },
                "required": [
                  "files"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "202": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "import_id": {
                      "type": "string"
                    },
                    "status_url": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "import_id",
                    "status_url"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Files validated and import queued"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Sign-in ended (import_link_signed_out) or another browser origin"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Import link expired",
            "unknown or spent": null
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Already uploading"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "429": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Too many uploads from this address"
          },
          "503": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Busy; try again in a few minutes with the same link"
          }
        },
        "security": [],
        "summary": "Upload a WordPress export and publish the converted site",
        "tags": [
          "Deploy"
        ]
      }
    },
    "/v1/me": {
      "delete": {
        "description": "Deletes the account at once and for good; nothing goes to Recently deleted and nothing can be restored. Removed: every site (including ones in Recently deleted) with its files, versions, saved data, lists and analytics; keys; connected apps; visitor sign-ins; and every entry this person sent to other people's lists while signed in; their address is taken off the changes they made to other people's page data (writes made without signing in and older public-list entries are not linked to anyone and stay; support@simple-host.app helps with those). The handle, old handles and claimed \u003cname\u003e.simple-host.app addresses are retired, never given to anyone else; custom domains are released. A confirmation email goes to the account's address. Offer GET /v1/me/export.zip first. Refused for a suspended account or one holding a site the operator took down (the operator handles those), an admin account, or one still holding event hostnames.",
        "operationId": "deleteMe",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "confirm": {
                    "description": "The account's handle, or its email when it has no handle. Case-insensitive.",
                    "type": "string"
                  }
                },
                "required": [
                  "confirm"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "204": {
            "description": "Deleted"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "confirm missing (code confirm_required) or does not match (code confirm_mismatch)"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Suspended account (code account_suspended), a site of the account taken down (code site_suspended) or admin account (code admin_account)"
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "The account still holds event hostnames (code event_hostnames)"
          },
          "429": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Too many requests from this address"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Delete your account and all its data",
        "tags": [
          "Account"
        ]
      },
      "get": {
        "operationId": "getMe",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "address": {
                      "$ref": "#/components/schemas/AddressState"
                    },
                    "can_set_keep_versions": {
                      "description": "Account is allowed to change per-site retention",
                      "type": "boolean"
                    },
                    "display_name": {
                      "type": "string"
                    },
                    "file_usage": {
                      "description": "Website files and kept versions including the live copy and Recently deleted sites; saved data and storage resources excluded",
                      "properties": {
                        "limit_mb": {
                          "description": "0 means no account file cap",
                          "type": "integer"
                        },
                        "message": {
                          "example": "12.3 MB of 1 GB used",
                          "type": "string"
                        },
                        "tips": {
                          "type": "string"
                        },
                        "used_bytes": {
                          "type": "integer"
                        }
                      },
                      "type": "object"
                    },
                    "handle": {
                      "description": "The \u003chandle\u003e in your addresses: sites at https://\u003csite\u003e.\u003chandle\u003e.simple-host.app/, your page at https://\u003chandle\u003e.simple-host.app/",
                      "type": "string"
                    },
                    "home_site": {
                      "description": "Chosen home site name; null means showcase. Rename follows the site, deletion clears it.",
                      "nullable": true,
                      "type": "string"
                    },
                    "id": {
                      "format": "uuid",
                      "type": "string"
                    },
                    "is_admin": {
                      "type": "boolean"
                    },
                    "keep_versions": {
                      "description": "Account default retention; 0 means all",
                      "type": "integer"
                    },
                    "max_site_mb": {
                      "description": "Largest site this account may deploy, in MB (an upload or its unpacked files over it is 413 site_too_large): the instance's MAX_ARCHIVE_MB, or the account's own MAX_ARCHIVE_MB_OVERRIDES entry. A site already larger stays up.",
                      "type": "integer"
                    },
                    "max_sites": {
                      "description": "Sites this account may hold (sites in Recently deleted count; one more is 403 site_quota_reached): the instance's MAX_SITES_PER_ACCOUNT, or the account's own MAX_SITES_OVERRIDES entry. Absent for admins, who have no limit.",
                      "type": "integer"
                    },
                    "public_page": {
                      "description": "The account's public page listing its public sites, e.g. https://\u003chandle\u003e.simple-host.app/. Present with handle.",
                      "format": "uri",
                      "type": "string"
                    },
                    "signin_alerts": {
                      "description": "Whether an email follows each sign-in or app connection (PATCH /v1/me to change).",
                      "type": "boolean"
                    },
                    "username": {
                      "type": "string"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "Current user"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Current user",
        "tags": [
          "Account"
        ]
      },
      "patch": {
        "description": "Changing the handle changes every address (https://\u003chandle\u003e.simple-host.app/ and https://\u003csite\u003e.\u003chandle\u003e.simple-host.app/). Before anything is published it changes freely. After that (even once every site is gone) it can change once every 30 days: the old handle stays reserved for this account and every old address redirects to the new one. A new handle gets its own certificate; until it is issued, sites briefly use the fallback https://\u003chandle\u003e.simple-host.app/\u003csite\u003e/. What pages kept in visitors' browsers starts empty at the new address, and visitors sign in again.",
        "operationId": "patchMe",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "minProperties": 1,
                "properties": {
                  "display_name": {
                    "description": "Trimmed; empty clears the name. Never used in URLs.",
                    "maxLength": 100,
                    "type": "string"
                  },
                  "handle": {
                    "description": "The \u003chandle\u003e in your address https://\u003chandle\u003e.simple-host.app/. Must not be reserved or already taken (handles and free \u003cname\u003e.simple-host.app addresses share one namespace).",
                    "pattern": "^[a-z0-9-]{1,39}$",
                    "type": "string"
                  },
                  "signin_alerts": {
                    "description": "Sign-in alert emails (default true): one email after each sign-in or app connection, at most one per browser or app a day. Needs one of the account's own API keys (a connected app or the admin key gets 400 not_an_account_key).",
                    "type": "boolean"
                  }
                },
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Updated account profile"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Invalid profile, or signin_alerts sent without an account key (code not_an_account_key)"
          },
          "401": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "API key required"
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "The handle cannot be had: code handle_taken (another account's handle or old handle, or a site's claimed free name; error says \"That address is taken: \u003chandle\u003e.simple-host.app is already in use. Try another.\") or handle_reserved. A malformed handle is 400 invalid_handle."
          },
          "429": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "next_change_after": {
                      "format": "date-time",
                      "type": "string"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "The handle already changed within the last 30 days after publishing"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Edit display name or change your handle",
        "tags": [
          "Account"
        ]
      }
    },
    "/v1/me/address-families": {
      "get": {
        "description": "Every address family of the account, pending or verified. `available` says whether this server offers address families. Owner only (API key).",
        "operationId": "listAddressFamilies",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "address_families": {
                      "items": {
                        "$ref": "#/components/schemas/AddressFamily"
                      },
                      "type": "array"
                    },
                    "available": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "address_families",
                    "available"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "The account's families"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "The account's address families",
        "tags": [
          "Address families"
        ]
      },
      "post": {
        "description": "An address family: the account connects `*.\u003csuffix\u003e` once (e.g. `*.trips.example.com`), and every site of the account named `\u003csite_prefix\u003e\u003clabel\u003e` then answers at `https://\u003clabel\u003e.\u003csuffix\u003e/`, including sites created later. `site_prefix` is optional (with `voucher-`, `meera.voucher.example.com` is the site `voucher-meera`). All the account's matching sites answer; another account's sites never do. The label is one DNS label; names in ADDRESS_FAMILY_RESERVED_LABELS (default `www`) never name a site.\n\nTwo DNS records prove it: the wildcard record `dns` (CNAME `*.\u003csuffix\u003e` to the CNAME target; `dns_a` is the A-record alternative) and the ownership record `dns_txt` (TXT `_simple-host.\u003csuffix\u003e` holding the family's token, kept in place). Nothing is served before both are seen. A pending family expires after 24 hours (ADDRESS_FAMILY_UNPROVEN_HOURS). Several accounts may wait on the same name: the first to prove it wins, and the others are dropped and emailed. Once verified the family is exclusive: no other account can connect it, a family overlapping it, or a custom domain under it (409 domain_taken). The same account may put a custom domain under its own family; the exact domain wins.\n\nCertificates: the operator sets up a wildcard certificate for `*.\u003csuffix\u003e` (`cert_mode` `wildcard`, the only mode now). Until then `certificate.status` is `waiting_for_operator` and nothing is served; write to support@simple-host.app. `per_host` (a certificate per site name) is not available yet (400 cert_mode_unavailable).\n\nMain address (`canonical`, default true): a site's main address is its custom domain or free name, else its most specific canonical family address (longest site_prefix, then lowest rank, then suffix), else its own address. When a family address is the main one, the site's own address redirects there (302) and sign-in and saves happen there (the own address refuses writes with use_custom_domain). Every family address of a site keeps working either way. `canonical: false` keeps the site's own address as the main one.\n\nA verified family that fails its checks (the TXT record gone, the wildcard moved) is emailed about after 24 hours and disconnected after 72; connecting it again needs the TXT record again. Answers 200 with the existing family when the account already has it. Owner only (API key).",
        "operationId": "createAddressFamily",
        "requestBody": {
          "content": {
            "application/json": {
              "example": {
                "suffix": "*.trips.example.com"
              },
              "schema": {
                "$ref": "#/components/schemas/AddressFamilyRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AddressFamily"
                }
              }
            },
            "description": "The account already has this family; it is returned unchanged"
          },
          "201": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AddressFamily"
                }
              }
            },
            "description": "Connected; pending until both DNS records are seen"
          },
          "400": {
            "content": {
              "application/json": {
                "example": {
                  "code": "invalid_rank",
                  "error": "rank must be between 0 and 1000"
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "invalid_domain (not a domain you can own, a public suffix, an internationalised name, or the platform's own, e.g. simple-host.app or simple-hack.app), invalid_site_prefix (lowercase letters, digits and hyphens, not at the start, up to 40 characters), invalid_rank (0 to 1000), cert_mode_unavailable (per_host), invalid_cert_mode."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "account_suspended"
          },
          "409": {
            "content": {
              "application/json": {
                "example": {
                  "code": "domain_taken",
                  "error": "*.trips.example.com (or a name under it) is connected to another account"
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "domain_taken (the name, or one over or under it, is connected to another account), address_family_limit (ADDRESS_FAMILIES_PER_ACCOUNT reached), address_families_unavailable (this server does not offer address families)."
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Connect *.\u003cdomain\u003e so every site of the account answers under it",
        "tags": [
          "Address families"
        ]
      }
    },
    "/v1/me/address-families/{suffix}": {
      "delete": {
        "description": "Every site stops answering under the family at once and goes back to its own address (or its domain). Connecting it again needs the TXT record again. Owner only (API key).",
        "operationId": "deleteAddressFamily",
        "responses": {
          "204": {
            "description": "Disconnected"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "no_address_family"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Disconnect an address family",
        "tags": [
          "Address families"
        ]
      },
      "get": {
        "description": "The family, with its DNS records, status and certificate. `?sites=1` adds `sites`, every matching site and its address under the family. Owner only (API key).",
        "operationId": "getAddressFamily",
        "parameters": [
          {
            "description": "1: list every matching site and its address",
            "in": "query",
            "name": "sites",
            "required": false,
            "schema": {
              "enum": [
                "1"
              ],
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AddressFamily"
                }
              }
            },
            "description": "The family"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "content": {
              "application/json": {
                "example": {
                  "code": "no_address_family",
                  "error": "no address family *.trips.example.com on this account"
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "no_address_family"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "One address family",
        "tags": [
          "Address families"
        ]
      },
      "parameters": [
        {
          "$ref": "#/components/parameters/familySuffix"
        }
      ],
      "patch": {
        "description": "Send any of `site_prefix`, `rank` and `canonical`; the others stay. Changing the prefix changes which sites answer at once. Owner only (API key).",
        "operationId": "patchAddressFamily",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "canonical": {
                    "type": "boolean"
                  },
                  "rank": {
                    "maximum": 1000,
                    "minimum": 0,
                    "type": "integer"
                  },
                  "site_prefix": {
                    "example": "voucher-",
                    "type": "string"
                  }
                },
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AddressFamily"
                }
              }
            },
            "description": "The family after the change"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "invalid_site_prefix or invalid_rank"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "no_address_family"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Change a family's site prefix, rank or main-address switch",
        "tags": [
          "Address families"
        ]
      }
    },
    "/v1/me/address-families/{suffix}/check": {
      "parameters": [
        {
          "$ref": "#/components/parameters/familySuffix"
        }
      ],
      "post": {
        "description": "Proves the family now (the TXT record, a random name under the wildcard resolving only to this server and, once live, one site answering over HTTPS) instead of waiting for the background check (every 10 minutes), then answers like GET with the new `status` and `last_error`. Owner only (API key); rate-limited per address and per account (429 rate_limited).",
        "operationId": "checkAddressFamily",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AddressFamily"
                }
              }
            },
            "description": "The family after the check"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "no_address_family (none, or it was let go)"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Check an address family again now",
        "tags": [
          "Address families"
        ]
      }
    },
    "/v1/me/api-key/rotate": {
      "post": {
        "description": "The \"Sign out everywhere\" button. Replaces all of the authenticated owner's API keys with one new key and returns it. Every older key stops working immediately, and every connected app (see /v1/me/connections) is disconnected. Keys are stored only as SHA-256 hashes, so this response is the only time the new key is shown.",
        "operationId": "postMeApiKeyRotate",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "api_key": {
                      "type": "string"
                    },
                    "message": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "api_key",
                    "message"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "API key rotated"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Sign out everywhere (rotate your API key)",
        "tags": [
          "Account"
        ]
      }
    },
    "/v1/me/bio": {
      "get": {
        "description": "Owner key or connector only, never deploy-only keys. Available on hosted Simple Host and small-box path installs, outside hosted events.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "bio": {
                      "type": "string"
                    },
                    "max_length": {
                      "default": 280,
                      "maximum": 2000,
                      "minimum": 1,
                      "type": "integer"
                    }
                  },
                  "required": [
                    "bio",
                    "max_length"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Plain-text bio and character limit"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Deploy-only key refused"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Showcase settings unavailable"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Get your public showcase bio and its configured limit",
        "tags": [
          "Account"
        ]
      },
      "put": {
        "description": "Trimmed plain text, empty clears. SHOWCASE_BIO_MAX_LENGTH controls the character limit (default 280, range 1–2000). Used by both public HTML and JSON feed. Owner key or connector only, excluding deploy-only keys and hosted events.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "additionalProperties": false,
                "properties": {
                  "bio": {
                    "type": "string"
                  }
                },
                "required": [
                  "bio"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "bio": {
                      "type": "string"
                    },
                    "max_length": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "bio",
                    "max_length"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Saved plain-text bio and configured limit"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing bio or bio_too_long"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Deploy-only key refused"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Showcase settings unavailable"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Save your public showcase plain-text bio",
        "tags": [
          "Account"
        ]
      }
    },
    "/v1/me/connections": {
      "get": {
        "description": "The AI apps (ChatGPT, Claude, Grok, a GPT Action) this account has connected through the Simple Host connector (OAuth at /oauth/authorize, MCP at /mcp). Each is listed once, however many times it was connected.",
        "operationId": "getMeConnections",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "connections": {
                      "items": {
                        "properties": {
                          "client_id": {
                            "type": "string"
                          },
                          "connected_at": {
                            "format": "date-time",
                            "type": "string"
                          },
                          "device": {
                            "description": "The browser the connection was approved on, summarised, to tell two connections of the same app apart. Never the user agent itself or an IP. Empty when unknown (and for connections made before 2026-09-27).",
                            "example": "Chrome on macOS",
                            "type": "string"
                          },
                          "last_used_at": {
                            "format": "date-time",
                            "type": "string"
                          },
                          "name": {
                            "description": "The name the app registered with",
                            "type": "string"
                          }
                        },
                        "required": [
                          "client_id",
                          "name",
                          "connected_at",
                          "last_used_at"
                        ],
                        "type": "object"
                      },
                      "type": "array"
                    }
                  },
                  "required": [
                    "connections"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Connected apps, newest first"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "List connected apps",
        "tags": [
          "Account"
        ]
      }
    },
    "/v1/me/connections/{client_id}": {
      "delete": {
        "description": "Revokes every token this account granted to the app. It has to be connected again, with a fresh sign-in, to be used.",
        "operationId": "deleteMeConnection",
        "parameters": [
          {
            "in": "path",
            "name": "client_id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Disconnected"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "No connected app with that id"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Disconnect an app",
        "tags": [
          "Account"
        ]
      }
    },
    "/v1/me/deleted-sites": {
      "get": {
        "description": "Sites this account deleted in the last 7 days. Each keeps its files, versions, saved data, collections and addresses, and its name stays taken, until purge_at; POST /v1/sites/{sitename}/restore brings it back until then.",
        "operationId": "getMeDeletedSites",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "retention_days": {
                      "example": 7,
                      "type": "integer"
                    },
                    "sites": {
                      "items": {
                        "properties": {
                          "custom_domain": {
                            "type": "string"
                          },
                          "deleted_at": {
                            "format": "date-time",
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          },
                          "purge_at": {
                            "description": "When it is removed for good",
                            "format": "date-time",
                            "type": "string"
                          }
                        },
                        "required": [
                          "name",
                          "deleted_at",
                          "purge_at"
                        ],
                        "type": "object"
                      },
                      "type": "array"
                    }
                  },
                  "required": [
                    "sites",
                    "retention_days"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Recently deleted sites, most recently deleted first"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "List recently deleted sites",
        "tags": [
          "Account"
        ]
      }
    },
    "/v1/me/email": {
      "post": {
        "description": "Emails a 6-digit code to the new address and another to the current one (15 minutes, 3 tries). Send both to POST /v1/me/email/verify to finish. Asking looks the same for every address. Needs one of the account's own API keys: a connected app or the admin key gets 400 not_an_account_key. Refused for the plugin reviewer account (and for moving any account to its address), admin, event and preview accounts, and accounts whose sign-in name is not an email address. Rate-limited per address and per account, like sign-in.",
        "operationId": "requestEmailChange",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "email": {
                    "format": "email",
                    "type": "string"
                  }
                },
                "required": [
                  "email"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "202": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "current_email": {
                      "description": "The current address (current_code)",
                      "type": "string"
                    },
                    "email": {
                      "description": "The new address (code)",
                      "type": "string"
                    },
                    "expires_in_seconds": {
                      "type": "integer"
                    },
                    "message": {
                      "type": "string"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "Codes sent"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Not an account key (code not_an_account_key), not an email (code invalid_email), already the sign-in email (code same_email), or the reviewer's address (code reserved_email)"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "The reviewer, an admin, event or preview account, or one with no email to confirm from (codes reviewer_account, admin_account, event_account, preview_account, no_current_email)"
          },
          "429": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Too many requests"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Change your sign-in email, step 1: send codes to the new and the current address",
        "tags": [
          "Account"
        ]
      }
    },
    "/v1/me/email/undo": {
      "get": {
        "description": "Shows a page with an Undo button and changes nothing. No sign-in; the token is the authorization. 404 page when the link was used or is older than 7 days. Emailed links carry the token in the fragment (`#t=`), so it never reaches a server log: without `t` the GET returns a small page whose script posts the fragment's token back with `peek=1`, which shows this confirmation. Links sent before v0.7.1 carry `?t=` and keep working until they expire.",
        "operationId": "getEmailChangeUndo",
        "parameters": [
          {
            "in": "query",
            "name": "t",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "HTML confirmation page with the button"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "HTML page; the link was used or has expired"
          },
          "429": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Too many requests"
          }
        },
        "security": [],
        "summary": "\"This wasn't me\" link from the old address's notice (confirmation page)",
        "tags": [
          "Account"
        ]
      },
      "post": {
        "description": "The confirmation page's button (with `peek=1`, only the confirmation page). Puts the account back on the old address, signs out every API key and every sign-in on sites, removes Google/GitHub sign-ins and connected apps added since the change, and spends every undo link of the account. HTML page.",
        "operationId": "postEmailChangeUndo",
        "requestBody": {
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "properties": {
                  "peek": {
                    "enum": [
                      "1"
                    ],
                    "type": "string"
                  },
                  "t": {
                    "type": "string"
                  }
                },
                "required": [
                  "t"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "HTML page; the account signs in with the old address again"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "HTML page; the link was used or has expired"
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "HTML page; another account signs in with the old address now"
          },
          "429": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Too many requests"
          }
        },
        "security": [],
        "summary": "Undo a sign-in email change",
        "tags": [
          "Account"
        ]
      }
    },
    "/v1/me/email/verify": {
      "post": {
        "description": "Moves the account to the new address: from now on sign-in codes go there. The handle, sites and connected apps stay; every other API key of the account is signed out (the one making the call stays); linked Google/GitHub sign-ins stay (GET /v1/me/identities lists them). Open sign-in codes for the old address stop working, the account's emailed idle-site links end, and the old address gets a notice naming the new one masked (n***@example.com) with an undo link valid for 7 days. An address another account signs in with is refused here, after the codes are verified (409 email_taken). Needs one of the account's own API keys.",
        "operationId": "verifyEmailChange",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "code": {
                    "description": "The 6-digit code emailed to the new address",
                    "type": "string"
                  },
                  "current_code": {
                    "description": "The 6-digit code emailed to the current address",
                    "type": "string"
                  }
                },
                "required": [
                  "code",
                  "current_code"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "email": {
                      "type": "string"
                    },
                    "message": {
                      "type": "string"
                    },
                    "username": {
                      "type": "string"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "Changed"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Not an account key (code not_an_account_key), a code missing (code codes_required), no change waiting (code no_pending_change), or the reviewer's address (code reserved_email)"
          },
          "401": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Wrong or expired code, or too many tries (code invalid_code)"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "The reviewer, an admin, event or preview account (see step 1)"
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Another account signs in with that address (code email_taken)"
          },
          "429": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Too many requests"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Change your sign-in email, step 2: confirm with both codes",
        "tags": [
          "Account"
        ]
      }
    },
    "/v1/me/export.tar.gz": {
      "get": {
        "deprecated": true,
        "description": "The address Download my data had before it became a .zip. Serves exactly what GET /v1/me/export.zip serves: the same .zip, with Content-Type application/zip and a .zip filename, under the same rules. Use /v1/me/export.zip.",
        "operationId": "exportMeLegacy",
        "responses": {
          "200": {
            "content": {
              "application/zip": {
                "schema": {
                  "format": "binary",
                  "type": "string"
                }
              }
            },
            "description": "The same .zip as GET /v1/me/export.zip"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Not one of the account's own API keys (code not_an_account_key)"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "The account is suspended (code account_suspended)"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Download all your data (older address)",
        "tags": [
          "Account"
        ]
      }
    },
    "/v1/me/export.zip": {
      "get": {
        "description": "Streams one .zip of everything held about the account: README.txt; account.json (email, handle, display name, created, earlier handles, claimed names including retired ones, custom domains, linked Google/GitHub sign-ins); keys.json (name, last 4 characters, created, last used; never keys or hashes); connected_apps.json (name, connected, last used); visitor.json (sites signed in to as a visitor with first sign-in and last seen, every entry sent to other people's lists while signed in, and `changed`: every change made to other people's page data or list entries while signed in, as site, what, change and when); sites/\u003cname\u003e/ for every live site in the same layout as GET /v1/sites/{sitename}/export.tar.gz (files/, state.json, collections.json); and recently-deleted/\u003cname\u003e/ for every site in Recently deleted, in the same layout plus deleted.json (deleted_at, removed_for_good_at). Visitor analytics hold no personal data (salted hashes) and are not included. Needs one of the account's own API keys: a connected app or the admin key gets 400 not_an_account_key. A suspended account gets 403 account_suspended and writes to support@simple-host.app for a copy of its data.",
        "operationId": "exportMe",
        "responses": {
          "200": {
            "content": {
              "application/zip": {
                "schema": {
                  "format": "binary",
                  "type": "string"
                }
              }
            },
            "description": "A .zip file (Content-Disposition names it simple-host-\u003chandle\u003e-\u003cdate\u003e.zip)"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Not one of the account's own API keys (code not_an_account_key)"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "The account is suspended (code account_suspended); write to support@simple-host.app for a copy of the data"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Download all your data (one .zip)",
        "tags": [
          "Account"
        ]
      }
    },
    "/v1/me/home": {
      "get": {
        "description": "Owner key or connector only; deploy-only keys refused. Available on hosted Simple Host and small-box path installs, outside hosted events. Null selects the showcase. Rename follows the site ID; deleting it clears the setting.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "site": {
                      "nullable": true,
                      "type": "string"
                    }
                  },
                  "required": [
                    "site"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Home-page choice"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Deploy-only key refused"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Personal homes unavailable in hosted events mode"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Get your home-page choice",
        "tags": [
          "Account"
        ]
      },
      "put": {
        "description": "The hosted person address serves the chosen site at root, with its passcode, sign-in and storage. On a small-box path install the public person page redirects to the chosen site’s existing URL; the owner dashboard keeps its address. Normal site address stays available. Offline, taken-down or suspended homes fall back to the showcase. A missing path beginning with another site name redirects to that site. No changes to other accounts.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "additionalProperties": false,
                "properties": {
                  "site": {
                    "example": "portfolio",
                    "nullable": true,
                    "type": "string"
                  }
                },
                "required": [
                  "site"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "site": {
                      "nullable": true,
                      "type": "string"
                    }
                  },
                  "required": [
                    "site"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Saved choice"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Invalid choice"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Deploy-only key refused"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Site not owned or deleted",
            "or hosted events mode": null
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Site requires the legacy content host"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Choose your showcase or one of your sites as home",
        "tags": [
          "Account"
        ]
      }
    },
    "/v1/me/identities": {
      "get": {
        "description": "Own API key only (400 not_an_account_key otherwise).",
        "operationId": "listIdentities",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "identities": {
                      "items": {
                        "properties": {
                          "email": {
                            "type": "string"
                          },
                          "id": {
                            "type": "string"
                          },
                          "linked_at": {
                            "format": "date-time",
                            "type": "string"
                          },
                          "provider": {
                            "enum": [
                              "google",
                              "github"
                            ],
                            "type": "string"
                          }
                        },
                        "type": "object"
                      },
                      "type": "array"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "The linked sign-ins"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Not an account key (code not_an_account_key)"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Google and GitHub accounts that sign in to yours",
        "tags": [
          "Account"
        ]
      }
    },
    "/v1/me/identities/{id}": {
      "delete": {
        "description": "That Google or GitHub account no longer signs in to yours. Own API key only.",
        "operationId": "unlinkIdentity",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Unlinked"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Not an account key (code not_an_account_key)"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "No such sign-in on this account"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Unlink a Google or GitHub sign-in",
        "tags": [
          "Account"
        ]
      }
    },
    "/v1/me/keys": {
      "get": {
        "description": "Every key on the account, newest first. `current` marks the key this request was made with. Keys issued before names existed have no `name` or `last4` (shown as \"Earlier key\"). `last_used_at` is updated at most every 5 minutes. A key unused for 180 days stops working. `idle_expires_at` says when (KEY_IDLE_EXPIRY_DAYS); one minted with `expires_in_days` stops at `expires_at`. A key that has stopped stays listed with `expired` until revoked.",
        "operationId": "getMeKeys",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "keys": {
                      "items": {
                        "$ref": "#/components/schemas/APIKey"
                      },
                      "type": "array"
                    }
                  },
                  "required": [
                    "keys"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "The account's keys"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "List your API keys",
        "tags": [
          "Account"
        ]
      },
      "post": {
        "description": "Mints a new key with the given name (for a CI secret or another machine) and returns it once; it is stored only as a hash. `scope: deploy` makes a deploy-only key: it may create, update, roll back and list sites, read their versions and files, and make preview links; every other route (delete, rename, domains, keys, saved data and lists, analytics, the account, connecting apps, and the same through /mcp) answers 403 deploy_only_key. Use it for a CI secret. `expires_in_days` (1-3650) gives the key a fixed expiry; without it the key stops only when it goes unused (KEY_IDLE_EXPIRY_DAYS). Needs one of the account's own keys (the admin env key or a connected app gets 400 not_an_account_key). An account holds at most 50 keys (409 key_limit; keys that have stopped working do not count, and are deleted 30 days after they stop). A deploy key never carries admin powers. A deploy key can publish code that runs when you open your own site (signed in there, that code acts with your powers over its saved data); treat it like the site itself; a key revoked or rotated away mid-request gets 401 invalid_api_key.",
        "operationId": "postMeKey",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "expires_in_days": {
                    "description": "Optional fixed expiry, in days from now",
                    "maximum": 3650,
                    "minimum": 1,
                    "type": "integer"
                  },
                  "name": {
                    "description": "Any label except the names Simple Host gives its own keys (dashboard sign-in, agent sign-in, event account, reviewer sign-in, replacement key; 400)",
                    "example": "GitHub Actions",
                    "maxLength": 60,
                    "minLength": 1,
                    "type": "string"
                  },
                  "scope": {
                    "default": "full",
                    "description": "deploy: create, update, roll back and list sites and make preview links, nothing else",
                    "enum": [
                      "full",
                      "deploy"
                    ],
                    "type": "string"
                  }
                },
                "required": [
                  "name"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/APIKey"
                    },
                    {
                      "properties": {
                        "api_key": {
                          "example": "shk_0123abcd…",
                          "type": "string"
                        },
                        "message": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "api_key"
                      ],
                      "type": "object"
                    }
                  ]
                }
              }
            },
            "description": "Key created. `api_key` is shown only here."
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "The account already holds 50 keys (code key_limit); revoke one first."
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Create a named API key",
        "tags": [
          "Account"
        ]
      }
    },
    "/v1/me/keys/{id}": {
      "delete": {
        "description": "The key stops working at once; the account's other keys and connected apps are untouched. Revoking the key this request uses signs this caller out. Needs one of the account's own keys.",
        "operationId": "deleteMeKey",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Revoked"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Not one of the account's own keys (code not_an_account_key)"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "No key with that id on this account"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Revoke one API key",
        "tags": [
          "Account"
        ]
      }
    },
    "/v1/me/sign-out": {
      "post": {
        "description": "Deletes the API key sent with this request, so it stops working everywhere; the account's other keys and connected apps are untouched. The dashboard's Sign out calls this before it clears the key from the browser. Needs one of the account's own keys (the admin env key or a connected app gets 400 not_an_account_key).",
        "operationId": "postMeSignOut",
        "responses": {
          "204": {
            "description": "Signed out; the key no longer works"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Not one of the account's own keys (code not_an_account_key)"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Sign out (end the key this request used)",
        "tags": [
          "Account"
        ]
      }
    },
    "/v1/notifications": {
      "get": {
        "description": "Owner credentials required. Simple Hack and Enterprise are not offered in phase 1.",
        "operationId": "listNotifications",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationList"
                }
              }
            },
            "description": "List notifications across the account"
          },
          "401": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing or invalid owner credentials."
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationError"
                }
              }
            },
            "description": "not_found, or not_offered on Simple Hack."
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "List notifications across the account",
        "tags": [
          "Notifications"
        ]
      }
    },
    "/v1/photo-links": {
      "get": {
        "description": "The create_upload_link photos_url opens /photos?site=\u003cname\u003e#t=\u003ctoken\u003e. The page sends the fragment token in X-Photo-Token, never in a request URL or Referer, and immediately removes the fragment from browser history. No browser sign-in is needed. The link lasts 60 minutes for OAuth callers; API-key links are capped at the key’s known expiry (reported in expires_at), and permits up to 10 successful saves to one site. The creating OAuth grant (not the original access token) or API key is checked again on every request; only the site name is returned, with no account data or keys. The binding is to the site name for the link’s lifetime: renaming and recreating that name sends later photos to the replacement site within the same account. Apex Host and Origin checks and IP rate limits apply. Simple Hack returns 404.",
        "operationId": "getPhotoLink",
        "parameters": [
          {
            "in": "header",
            "name": "X-Photo-Token",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "site": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "site"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "The site this link can add photos to"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Origin refused or creating sign-in revoked"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "This photo link has expired. Ask your chat for a new one."
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "A save is in progress"
          },
          "429": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Rate limit exceeded"
          }
        },
        "security": [],
        "summary": "Check a photo link and return its fixed site",
        "tags": [
          "Deploy"
        ]
      },
      "post": {
        "description": "Uses the same X-Photo-Token, sign-in recheck, Origin policy, and rate limit as GET. Proxies only to POST /v1/sites/{sitename}/photos for the site fixed when the link was created. Query parameters cannot change the destination. The same multipart, body, photo, and storage caps apply, and all other live files are kept. Successful saves consume one of 10 uses; failed saves can be retried until expiry. Short-lived credentials are revoked after each request and never leave the server. In-memory links expire on server restart. Simple Hack returns 404.",
        "operationId": "savePhotoLink",
        "parameters": [
          {
            "in": "header",
            "name": "X-Photo-Token",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "$ref": "#/paths/~1v1~1sites~1{sitename}~1photos/post/requestBody"
        },
        "responses": {
          "200": {
            "$ref": "#/paths/~1v1~1sites~1{sitename}~1photos/post/responses/200"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Origin or deploy permission refused",
            "or creating sign-in revoked": null
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Site missing, or this photo link has expired. Ask your chat for a new one."
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "A save is in progress or a file/directory conflicts with the photo path"
          },
          "413": {
            "account": null,
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Photo",
            "or request size limit exceeded": null,
            "site": null
          },
          "429": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Rate limit or photo save capacity exceeded; Retry-After advises when to retry"
          }
        },
        "security": [],
        "summary": "Add photos through a site-bound connector link",
        "tags": [
          "Deploy"
        ]
      }
    },
    "/v1/site-signin": {
      "post": {
        "description": "The form on a named-viewers site's sign-in page posts here, on the site's own host; no key, no JavaScript. With `email` alone it sends a sign-in code (to any address, listed or not, so the form never says who is on the list) and answers the code page (401, text/html). With `email` and `code` it signs the visitor in to this site on this host (the same visitor session as auth.js) and answers 303 to `next`. Only a form from a page on the same host is accepted (403 origin_not_allowed otherwise). On a site that is not specific it answers 303 to `next`. Rate limited per address and per email like visitor sign-in.",
        "operationId": "postSiteSignin",
        "requestBody": {
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "properties": {
                  "code": {
                    "type": "string"
                  },
                  "email": {
                    "type": "string"
                  },
                  "next": {
                    "description": "A path on this host to return to (default /)",
                    "example": "/",
                    "type": "string"
                  }
                },
                "required": [
                  "email"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "303": {
            "description": "Signed in; go back to `next`",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              },
              "Set-Cookie": {
                "description": "The visitor session cookie __Host-sh_vsess for this host",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Not a form post"
          },
          "401": {
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "description": "The code page, or the sign-in page again with the reason (text/html, noindex, no-store)"
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          }
        },
        "summary": "Sign in from a named-viewers site's sign-in page",
        "tags": [
          "Visitor sign-in"
        ]
      }
    },
    "/v1/site-signout": {
      "post": {
        "description": "The Switch account and Sign out buttons on \"This site is private\" post here, on the site's own host. Ends this host's visitor session and answers 303 to `next`, which then shows the sign-in page. Only a form from a page on the same host is accepted.",
        "operationId": "postSiteSignout",
        "requestBody": {
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "properties": {
                  "next": {
                    "example": "/",
                    "type": "string"
                  },
                  "then": {
                    "enum": [
                      "switch",
                      "out"
                    ],
                    "type": "string"
                  }
                },
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "303": {
            "description": "Signed out; go back to `next`",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Not a form post"
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          }
        },
        "summary": "Sign out from a named-viewers site's private page",
        "tags": [
          "Visitor sign-in"
        ]
      }
    },
    "/v1/site-unlock": {
      "post": {
        "description": "The form on the \"This site is protected\" page posts here, on the site's own host; no key. The site is the one the host names (on a person host, the first path segment of `next`). Only a form from a page on the same host is accepted (another origin gets 403 origin_not_allowed as JSON). A right passcode answers 303 to `next` with the unlock cookie for this host. A wrong one answers the gate page again (401). Wrong tries are limited per site and address (RATE_LIMIT_PASSCODE_IP, default 5 per 3 minutes, then PASSCODE_LOCKOUT_MINUTES, default 15) and per site from everyone together (RATE_LIMIT_PASSCODE_SITE, default 60 a minute, then PASSCODE_SITE_LOCKOUT_MINUTES, default 15, when every try is refused; visitors already let in are not affected): 429 with Retry-After.",
        "operationId": "postSiteUnlock",
        "requestBody": {
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "properties": {
                  "next": {
                    "description": "A path on this host to return to (default /)",
                    "example": "/",
                    "type": "string"
                  },
                  "passcode": {
                    "type": "string"
                  }
                },
                "required": [
                  "passcode"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "303": {
            "description": "Right passcode; go back to `next`",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              },
              "Set-Cookie": {
                "description": "The unlock cookie: __Host-sh_pass_\u003cid\u003e, HttpOnly, Secure, SameSite=Lax, Path=/, about 400 days",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Not a form post"
          },
          "401": {
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "description": "Wrong passcode: the gate page again, saying so (text/html, noindex, no-store)"
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          },
          "429": {
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "description": "Too many wrong tries: the gate page saying when to try again (text/html)",
            "headers": {
              "Retry-After": {
                "description": "Seconds",
                "schema": {
                  "type": "integer"
                }
              }
            }
          }
        },
        "summary": "Enter a site's passcode (the \"This site is protected\" form)",
        "tags": [
          "Visitor sign-in"
        ]
      }
    },
    "/v1/sites": {
      "get": {
        "description": "Returns the authenticated user's sites. Admins see all sites. On simple-hack.app (EVENTS=hosted) only a team key (or a connector bound to a team) deploys, to its own team's site only; errors 403 no_personal_sites, 403 team_site_only, 403 team_key_scope, 401 team_key_inactive, 409 team_sites_not_ready, 409 submissions_closed, 403 team_site_taken_down.",
        "operationId": "getSites",
        "parameters": [
          {
            "description": "Every word must match the name, domain or summary (case-insensitive substrings).",
            "in": "query",
            "name": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Created on or after RFC3339 or YYYY-MM-DD (UTC midnight).",
            "in": "query",
            "name": "created_after",
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Created on or before RFC3339 or YYYY-MM-DD (UTC whole day).",
            "in": "query",
            "name": "created_before",
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Updated on or after RFC3339 or YYYY-MM-DD (UTC midnight).",
            "in": "query",
            "name": "updated_after",
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Updated on or before RFC3339 or YYYY-MM-DD (UTC whole day).",
            "in": "query",
            "name": "updated_before",
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Maximum results, clamped to 500; default 200 when any filter or paging parameter is present.",
            "in": "query",
            "name": "limit",
            "schema": {
              "minimum": 0,
              "type": "integer"
            }
          },
          {
            "description": "Skip this many matching sites, sorted newest deployment first (updated_at when no active version).",
            "in": "query",
            "name": "offset",
            "schema": {
              "default": 0,
              "minimum": 0,
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "items": {
                    "$ref": "#/components/schemas/Site"
                  },
                  "type": "array"
                }
              }
            },
            "description": "List of sites"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Invalid date (bad_date) or non-negative integer required for limit/offset (bad_pagination)."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "List your sites",
        "tags": [
          "Sites"
        ]
      }
    },
    "/v1/sites/{sitename}": {
      "delete": {
        "description": "Takes the site offline at once, with every version, its saved state and collections. It stays in Recently deleted (GET /v1/me/deleted-sites) for 7 days with its name held, and POST /v1/sites/{sitename}/restore brings it back exactly as it was; after that it is removed for good. Creating a site with a held name answers 409 with recently_deleted true.",
        "operationId": "deleteSitesBySitename",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "204": {
            "description": "Site deleted (restorable for 7 days)"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Delete your site",
        "tags": [
          "Sites"
        ]
      },
      "patch": {
        "description": "One change per request. With {\"offline\": true} the site is taken offline: every address answers a plain \"This site is offline\" page (503), and visitor saves (state and collection writes from a page, private lists included), visitors' reads of its state and lists, and visitor sign-in are refused with 403 site_offline; files, versions and saved data are kept, and the owner's key or connector still deploys, reads and writes. {\"offline\": false} puts it back online. An operator take-down wins over it, and a taken-down site cannot be switched (403 site_suspended). The answer is the site object with `offline` and a `note`. With {\"name\": \"new-name\"} it renames the database row and on-disk site directory. The new name uses the same validation and per-owner uniqueness rule as creation. A bound custom domain remains attached. Links to the old name keep working: its site host, person-path and sites.* addresses 302 to the new address (path and query kept), through any chain of renames, until a site with the old name is created again (the new site wins); the response says so with old_url_status \"redirects\". Deleting the site stops the redirects (restoring brings them back); purging it removes them.",
        "operationId": "patchSitesBySitename",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "description": "Exactly one of name or offline.",
                "properties": {
                  "name": {
                    "description": "The new name (renames the site).",
                    "example": "new-site-name",
                    "type": "string"
                  },
                  "offline": {
                    "description": "true takes the site offline; false puts it back online.",
                    "type": "boolean"
                  }
                },
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Renamed (old_name, name, old_url, site_url, old_url_status) or switched (the site object with offline and note)"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "The operator has taken the site down (code site_suspended)"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Rename your site, or take it offline",
        "tags": [
          "Sites"
        ]
      },
      "post": {
        "description": "Upload a `.tar.gz` or `.zip` (for framework builds, binary assets, large sites). Max 100 MB. On simple-hack.app (EVENTS=hosted) only a team key (or a connector bound to a team) deploys, to its own team's site only; errors 403 no_personal_sites, 403 team_site_only, 403 team_key_scope, 401 team_key_inactive, 409 team_sites_not_ready, 409 submissions_closed, 403 team_site_taken_down.",
        "operationId": "postSitesBySitename",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/createIdempotencyKey"
          }
        ],
        "requestBody": {
          "content": {
            "application/gzip": {
              "schema": {
                "format": "binary",
                "type": "string"
              }
            },
            "application/zip": {
              "schema": {
                "format": "binary",
                "type": "string"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteWithNote"
                }
              }
            },
            "description": "Site created"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Create a site from an archive",
        "tags": [
          "Deploy"
        ]
      },
      "put": {
        "description": "Upload a new version of an existing site you own. Creates a new version and activates it; with ?publish=false the version is only stored (visitors keep seeing the live one) and the answer adds unpublished_version, preview_url and preview_expires_at. With ?create=1 a missing site is created instead (201, with the same checks as POST): one call from CI, e.g. `curl -X PUT \".../v1/sites/\u003cname\u003e?create=1\" -H \"X-API-Key: $KEY\" --data-binary @site.tar.gz`. On simple-hack.app (EVENTS=hosted) only a team key (or a connector bound to a team) deploys, to its own team's site only; errors 403 no_personal_sites, 403 team_site_only, 403 team_key_scope, 401 team_key_inactive, 409 team_sites_not_ready, 409 submissions_closed, 403 team_site_taken_down.",
        "operationId": "putSitesBySitename",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/publish"
          },
          {
            "$ref": "#/components/parameters/create"
          }
        ],
        "requestBody": {
          "content": {
            "application/gzip": {
              "schema": {
                "format": "binary",
                "type": "string"
              }
            },
            "application/zip": {
              "schema": {
                "format": "binary",
                "type": "string"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteWithNote"
                }
              }
            },
            "description": "Site updated"
          },
          "201": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteWithNote"
                }
              }
            },
            "description": "Site created (?create=1 and no site of that name)"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Update a site from an archive",
        "tags": [
          "Deploy"
        ]
      }
    },
    "/v1/sites/{sitename}/access": {
      "get": {
        "description": "Owner only (the owner's key or connector token; a deploy-only key is refused). `access` is `anyone` (anyone with the address, or with the passcode when the site has one) or `specific` (only the owner and the named viewers, each signed in with visitor sign-in on the site's own address). `viewers` lists the named viewers by the email they sign in with. Never cached (Cache-Control: no-store). Hosted Simple Host and small boxes only; not on Simple Hack.",
        "operationId": "getSitesBySitenameAccess",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteAccess"
                }
              }
            },
            "description": "Who can open the site"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "A deploy-only key (deploy_only_key)"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Who can open a site, and its named viewers",
        "tags": [
          "Sites"
        ]
      },
      "put": {
        "description": "Owner only. `{\"access\": \"specific\"}`: every address of the site (its own address, a custom domain, a free name, an address family, the person address when it is the home page) then shows a sign-in page (401, noindex, no-store, nothing of the site in it) to anyone not signed in on that address, and a plain \"This site is private\" page (403) with Switch account and Sign out to someone signed in who is not the owner or a named viewer. The site's saved data (state, collections, storage resources, whatever their site_passcode says) answers 403 `site_private` to them too; the owner's key and connector keep working, and previews still open. With no viewers named only the owner can open it. `{\"access\": \"anyone\"}` opens it again and keeps the list. The site leaves the person page while it is specific. A site has named viewers or a passcode, never both (409 passcode_set). Every request is checked against the list as it is then: a removed person is refused on their next request.",
        "operationId": "putSitesBySitenameAccess",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "access": {
                    "enum": [
                      "anyone",
                      "specific"
                    ],
                    "type": "string"
                  }
                },
                "required": [
                  "access"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteAccess"
                }
              }
            },
            "description": "Who can open the site now, with a plain `note`"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Body is not {\"access\": \"anyone\"} or {\"access\": \"specific\"} (invalid_request)"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "The operator has taken the site down (site_suspended), or a deploy-only key (deploy_only_key)"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "passcode_set (remove the passcode first), named_viewers_need_own_address (every site on this server shares one address), sign_in_unavailable (this server has no email codes or Google), or named_viewers_unavailable (Simple Hack)"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Choose who can open a site (anyone, or only named viewers)",
        "tags": [
          "Sites"
        ]
      }
    },
    "/v1/sites/{sitename}/active-version": {
      "put": {
        "description": "Re-point the live site at an existing version number: an earlier one, or one stored with publish=false (which then counts as live). On simple-hack.app (EVENTS=hosted) only a team key (or a connector bound to a team) deploys, to its own team's site only; errors 403 no_personal_sites, 403 team_site_only, 403 team_key_scope, 401 team_key_inactive, 409 team_sites_not_ready, 409 submissions_closed, 403 team_site_taken_down.",
        "operationId": "putSitesBySitenameActiveVersion",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "version_number": {
                    "example": 2,
                    "type": "integer"
                  }
                },
                "required": [
                  "version_number"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Active version changed"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Roll back to a version, or make a stored version live",
        "tags": [
          "Versions"
        ]
      }
    },
    "/v1/sites/{sitename}/analytics": {
      "get": {
        "description": "Owner-scoped view and visitor counts derived server-side from the access log (no client beacon). Every bucket is split three ways by who was asking: `person` (no automation signature), `bot` (crawlers, AI scrapers, SEO tools, security scanners, HTTP libraries) and `infra` (uptime probes and health checks aimed at the site). Read `person` as the audience number — a total that folds in `infra` is dominated by monitoring, not people.\n\n`visitors` are unique visitors: COUNT(DISTINCT hashed IP) over the window asked for. The salt is stable, so `totals.*.visitors` is genuine uniques for the range and is NOT the sum of the `daily` figures — someone who visited on five days is one unique in `totals` and five in `daily`. IP-based, so a shared office NAT reads as one visitor and a phone switching wifi→cellular reads as two.\n\n`daily` is zero-filled dense for the requested window (oldest → newest). `hourly` is always 24 zero-filled buckets ending with the hour in progress. Only successful document responses (200/304, non-asset paths) count as views, for every class alike. Owner only (API key).",
        "operationId": "getSitesBySitenameAnalytics",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "description": "Number of UTC days to include (default 30, clamp 1..365)",
            "in": "query",
            "name": "days",
            "required": false,
            "schema": {
              "default": 30,
              "maximum": 365,
              "minimum": 1,
              "type": "integer"
            }
          },
          {
            "description": "Handle of the account that owns the site. Omit it (or pass your own handle) for your own site. Any other handle is honoured for the platform admin only; for everyone else it answers 404, the same as a site that does not exist.",
            "in": "query",
            "name": "owner",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "classified_from": {
                      "description": "First UTC day for which the person/bot/infra split exists. Earlier days in `daily` report under `unknown`. Omitted when nothing has been classified yet.",
                      "example": "2026-08-09",
                      "format": "date",
                      "type": "string"
                    },
                    "daily": {
                      "items": {
                        "allOf": [
                          {
                            "properties": {
                              "day": {
                                "example": "2026-08-31",
                                "format": "date",
                                "type": "string"
                              }
                            },
                            "type": "object"
                          },
                          {
                            "$ref": "#/components/schemas/TrafficSplit"
                          }
                        ]
                      },
                      "type": "array"
                    },
                    "hourly": {
                      "description": "Exactly 24 buckets, oldest → newest.",
                      "items": {
                        "allOf": [
                          {
                            "properties": {
                              "hour": {
                                "example": "2026-08-31T14:00:00Z",
                                "format": "date-time",
                                "type": "string"
                              }
                            },
                            "type": "object"
                          },
                          {
                            "$ref": "#/components/schemas/TrafficSplit"
                          }
                        ]
                      },
                      "type": "array"
                    },
                    "last_24h": {
                      "$ref": "#/components/schemas/TrafficSplit"
                    },
                    "range_days": {
                      "example": 30,
                      "type": "integer"
                    },
                    "totals": {
                      "$ref": "#/components/schemas/TrafficSplit"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "Analytics for the site"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Get per-site visitor analytics",
        "tags": [
          "Analytics"
        ]
      }
    },
    "/v1/sites/{sitename}/analytics/geo": {
      "get": {
        "description": "Per-country traffic for one site over the last `days` UTC days, split by the same `person` / `bot` / `infra` classes as the rest of analytics and ordered by `person.views` descending (ties broken by country code).\n\nCountry is resolved on the server from a local IP-range table refreshed from a public dataset — no visitor address is ever sent to a geolocation service, and the raw address is still never stored. Country data: \"IP Geolocation by DB-IP\" (https://db-ip.com), CC BY 4.0.\n\n`country` is ISO-3166 alpha-2. `\"XX\"` / `\"Unknown\"` is traffic the table could not place (an unlisted block, or a server whose ranges have not been loaded); it is listed rather than dropped, so the countries still add up to the site's totals.\n\n`visitors` is COUNT(DISTINCT hashed IP) over the whole window, exactly as in the main analytics endpoint — genuine uniques for the range, not the sum of the daily figures. Someone whose address changes country mid-range counts once in each, so the columns can total slightly more than the site's own figure. `unknown` is always zero here: the pre-classifier history has no addresses left to place. Owner only (API key).",
        "operationId": "getSitesBySitenameAnalyticsGeo",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "description": "Number of UTC days to include (default 30, clamp 1..365)",
            "in": "query",
            "name": "days",
            "required": false,
            "schema": {
              "default": 30,
              "maximum": 365,
              "minimum": 1,
              "type": "integer"
            }
          },
          {
            "description": "Handle of the account that owns the site. Omit it (or pass your own handle) for your own site. Any other handle is honoured for the platform admin only; for everyone else it answers 404, the same as a site that does not exist.",
            "in": "query",
            "name": "owner",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "countries": {
                      "items": {
                        "allOf": [
                          {
                            "properties": {
                              "country": {
                                "description": "ISO-3166 alpha-2, or \"XX\" when unresolved",
                                "example": "US",
                                "type": "string"
                              },
                              "country_name": {
                                "example": "United States",
                                "type": "string"
                              }
                            },
                            "type": "object"
                          },
                          {
                            "$ref": "#/components/schemas/TrafficSplit"
                          }
                        ]
                      },
                      "type": "array"
                    },
                    "range_days": {
                      "example": 30,
                      "type": "integer"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "Per-country traffic, most real people first."
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Where a site's visitors are, by country",
        "tags": [
          "Analytics"
        ]
      }
    },
    "/v1/sites/{sitename}/analytics/top": {
      "get": {
        "description": "The 20 most viewed pages (path on the site, without the query string or fragment, percent-escapes decoded once and `//` collapsed) and the 20 top referring domains over the last `days` UTC days, people only, most views first. A site keeps at most ANALYTICS_PAGES_PER_SITE_DAY pages and ANALYTICS_REFERRERS_PER_SITE_DAY domains a day; the views of further new ones that day are listed as `(other)`. A referrer is recorded as its domain alone — never a full address, path or query — and a link from the site's own address is not counted. Owner only (API key); the platform admin may pass `owner`.",
        "operationId": "getSitesBySitenameAnalyticsTop",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "description": "Number of UTC days to include (default 30, clamp 1..365)",
            "in": "query",
            "name": "days",
            "required": false,
            "schema": {
              "default": 30,
              "maximum": 365,
              "minimum": 1,
              "type": "integer"
            }
          },
          {
            "description": "Handle of the account that owns the site (platform admin only; anyone else gets 404).",
            "in": "query",
            "name": "owner",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "pages": {
                      "items": {
                        "properties": {
                          "path": {
                            "example": "/menu/",
                            "type": "string"
                          },
                          "views": {
                            "description": "Views by people",
                            "type": "integer"
                          }
                        },
                        "required": [
                          "path",
                          "views"
                        ],
                        "type": "object"
                      },
                      "type": "array"
                    },
                    "range_days": {
                      "example": 30,
                      "type": "integer"
                    },
                    "referrers": {
                      "items": {
                        "properties": {
                          "domain": {
                            "example": "news.ycombinator.com",
                            "type": "string"
                          },
                          "views": {
                            "description": "Views by people arriving from it",
                            "type": "integer"
                          }
                        },
                        "required": [
                          "domain",
                          "views"
                        ],
                        "type": "object"
                      },
                      "type": "array"
                    }
                  },
                  "required": [
                    "range_days",
                    "pages",
                    "referrers"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Top pages and referring domains"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "A site's top pages and where its visitors came from",
        "tags": [
          "Analytics"
        ]
      }
    },
    "/v1/sites/{sitename}/domain": {
      "delete": {
        "description": "Disconnects the domain GET shows. If it was still pending and the site kept serving at an earlier address (`previous_domain`), that address becomes the site's domain again; otherwise the site is served at its own address. A disconnected free \u003cname\u003e.simple-host.app stays with the site and redirects to its current address; links to a disconnected custom domain stop working. Owner only (API key). Pass `domain` (the address being disconnected, as GET showed it) so a domain changed in the meantime is never dropped by mistake.",
        "operationId": "deleteSitesBySitenameDomain",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "description": "The address you mean to disconnect. If the site's domain is something else by now, nothing changes and the answer is 409 `domain_changed`.",
            "in": "query",
            "name": "domain",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Domain unbound"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "`domain_changed`: the site's domain is not the one named in `domain`; nothing was changed."
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Disconnect this site's domain or free address",
        "tags": [
          "Custom domains"
        ]
      },
      "get": {
        "description": "Returns the bound domain and status, or `{domain:null,status:null}` when none is set. Includes the two DNS records to add when a custom domain is bound (`dns` and the TXT ownership record `dns_txt`), the certificate's progress, the earlier address still serving while the domain is pending, and `failing_since` when a verified domain has stopped passing its checks. Owner only (API key).",
        "operationId": "getSitesBySitenameDomain",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "bound_at": {
                      "description": "Binding timestamp stored as domain_bound_at; reset on bind",
                      "format": "date-time",
                      "type": "string"
                    },
                    "certificate_status": {
                      "description": "pending (DNS not pointed here yet), issuing, live, or failed (why in last_error; retried). Absent for a free \u003cname\u003e.simple-host.app address.",
                      "enum": [
                        "pending",
                        "issuing",
                        "live",
                        "failed"
                      ],
                      "type": "string"
                    },
                    "dns": {
                      "properties": {
                        "host": {
                          "type": "string"
                        },
                        "type": {
                          "example": "CNAME",
                          "type": "string"
                        },
                        "value": {
                          "type": "string"
                        }
                      },
                      "type": "object"
                    },
                    "dns_partner": {
                      "description": "The record that points the partner here too (A for a bare domain, CNAME for www). Present with partner_domain.",
                      "properties": {
                        "host": {
                          "example": "example.com",
                          "type": "string"
                        },
                        "type": {
                          "example": "A",
                          "type": "string"
                        },
                        "value": {
                          "example": "203.0.113.7",
                          "type": "string"
                        }
                      },
                      "type": "object"
                    },
                    "dns_txt": {
                      "description": "The ownership record: TXT _simple-host.\u003cdomain\u003e holding this site's token. Needed (and kept in place) before the domain is verified or gets a certificate. Absent for a free \u003cname\u003e.simple-host.app address.",
                      "properties": {
                        "host": {
                          "example": "_simple-host.www.example.com",
                          "type": "string"
                        },
                        "type": {
                          "example": "TXT",
                          "type": "string"
                        },
                        "value": {
                          "example": "sh-0123456789abcdef0123456789abcdef",
                          "type": "string"
                        }
                      },
                      "type": "object"
                    },
                    "domain": {
                      "example": "www.example.com",
                      "nullable": true,
                      "type": "string"
                    },
                    "expires_at": {
                      "description": "bound_at plus 24 hours; included only while pending and never verified",
                      "format": "date-time",
                      "type": "string"
                    },
                    "failing_since": {
                      "description": "A verified domain that stopped passing its checks: the owner is emailed after 24 hours; after 72 hours it stops being the site's address.",
                      "format": "date-time",
                      "type": "string"
                    },
                    "last_error": {
                      "description": "Why the domain is not active",
                      "from the latest check": null,
                      "type": "string"
                    },
                    "partner_domain": {
                      "description": "The domain's www / bare partner (www.\u003cdomain\u003e for a bare domain, the bare domain for www.\u003cdomain\u003e). It forwards (301) to the chosen name; the ownership record on the chosen name covers both. Absent for any other domain.",
                      "example": "example.com",
                      "type": "string"
                    },
                    "partner_note": {
                      "description": "Why the partner is not set up. Present only then.",
                      "type": "string"
                    },
                    "partner_status": {
                      "description": "pending: the chosen name has no certificate yet (the partner is added with it). live: it forwards to the chosen name. not_set_up: it is not on the certificate (partner_note says why: not pointed here, served by another site on this server, or connected to a site of its own); it is added automatically once fixed (checked every few hours).",
                      "enum": [
                        "pending",
                        "live",
                        "not_set_up"
                      ],
                      "type": "string"
                    },
                    "previous_domain": {
                      "description": "The site's earlier own address, still serving it until this domain is verified. Present only while the domain is pending.",
                      "type": "string"
                    },
                    "status": {
                      "enum": [
                        "pending",
                        "active",
                        "error"
                      ],
                      "nullable": true,
                      "type": "string"
                    },
                    "verified_at": {
                      "format": "date-time",
                      "nullable": true,
                      "type": "string"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "Current domain binding"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Get the custom domain binding for this site",
        "tags": [
          "Custom domains"
        ]
      },
      "post": {
        "description": "Optional. Every site already lives at its own address `https://\u003csite\u003e.\u003chandle\u003e.simple-host.app/`, with sign-in and private lists. This gives it a nicer address; the site moves there and its old address redirects. A free address: send `{\"domain\": \"\u003cname\u003e.simple-host.app\"}` (one DNS label: lowercase letters, digits and hyphens, not at the start or end, up to 63 characters). It is active at once with no DNS step — the answer has `status: active` and `verified_at`, and no `dns`. First come, first served: 409 domain_taken if another site holds it or it is already someone's handle (handles and free names share one namespace); reserved names (www, sites, api, admin, mail, cname and the platform's own hosts) get 400 name_reserved; anything else malformed gets 400 invalid_name. Once claimed it behaves exactly like a custom domain: the site is served at its root with a same-origin /v1 API and visitor sign-in, its person-address URL 302s there, and private collections take submissions there. A site has one own address: claiming one replaces a custom domain and vice versa. A free name the site lets go (switching, DELETE, or deleting the site) stays with it: it redirects to the site's current address and nobody else can claim it. A custom domain: bind ONE domain to the site (status starts as `pending`). Returns the two DNS records the owner must create: `dns` (CNAME for a subdomain, A for an apex) and `dns_txt`, the ownership record (TXT `_simple-host.\u003cdomain\u003e` holding this site's token), which proves the domain is theirs and must stay in place. Nothing is verified or certified without it. A name another site on this server already serves is refused by the certificate issuer (failed, \"already served here\"). Once both records are seen, its certificate is requested automatically (at most 5 new domain certificates per account per day) (`certificate_status`: pending → issuing → live, or failed with the reason in `last_error`, retried) and the domain is served at its root, usually within minutes. If the site already has a working own address (a free name or an earlier domain), it keeps serving there (`previous_domain`) until the new domain is verified; then the old address redirects to it. Binding the site's current working domain again changes nothing; binding its `previous_domain` drops the pending one. Until first verified, a binding is provisional: another site can take it over until it has passed its ownership proof, and it expires 24 hours after binding unless DNS already points here. A verified domain stays exclusive while it works; if it fails every check for 24 hours the owner is emailed, and after 72 hours it is disconnected (the site's own address serves again) and can be connected afresh, with the TXT record, by whoever holds it; while it is being released, binding it answers 409 domain_releasing. The platform's own zones (including event domains such as simple-hack.app) are refused (400 invalid_domain). A domain under the account's own address family may be connected; the exact domain wins. `*.\u003cdomain\u003e` answers 400 use_address_family (see POST /v1/me/address-families). Owner only (API key).\n\nwww and the bare domain (where the certificate issuer runs, as on simple-host.app; absent elsewhere): binding `example.com` also sets up `www.example.com` (and binding `www.example.com` sets up `example.com`) as a redirect-only host that forwards (301) to the name chosen. The answer names it (`partner_domain`) with the record it needs (`dns_partner`); the TXT record on the chosen name covers both. It goes on the same certificate only if it points here and no other site on this server answers it; otherwise the chosen name goes live alone and `partner_status` is `not_set_up` with `partner_note` saying why.",
        "operationId": "postSitesBySitenameDomain",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/createIdempotencyKey"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "domain": {
                    "example": "www.example.com",
                    "type": "string"
                  }
                },
                "required": [
                  "domain"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "bound_at": {
                      "format": "date-time",
                      "type": "string"
                    },
                    "certificate_status": {
                      "description": "The custom domain's certificate: pending (DNS not pointed here yet), issuing, live, or failed (why in last_error; retried). Absent for a free \u003cname\u003e.simple-host.app address.",
                      "enum": [
                        "pending",
                        "issuing",
                        "live",
                        "failed"
                      ],
                      "type": "string"
                    },
                    "dns": {
                      "description": "The record that points the domain here, to add at the registrar; absent for a free \u003cname\u003e.simple-host.app address",
                      "properties": {
                        "host": {
                          "example": "www.example.com",
                          "type": "string"
                        },
                        "type": {
                          "example": "CNAME",
                          "type": "string"
                        },
                        "value": {
                          "example": "cname.simple-host.app",
                          "type": "string"
                        }
                      },
                      "type": "object"
                    },
                    "dns_partner": {
                      "description": "The record that points the partner here too (A for a bare domain, CNAME for www). Present with partner_domain.",
                      "properties": {
                        "host": {
                          "example": "example.com",
                          "type": "string"
                        },
                        "type": {
                          "example": "A",
                          "type": "string"
                        },
                        "value": {
                          "example": "203.0.113.7",
                          "type": "string"
                        }
                      },
                      "type": "object"
                    },
                    "dns_txt": {
                      "description": "The ownership record: TXT _simple-host.\u003cdomain\u003e holding this site's token. Needed (and kept in place) before the domain is verified or gets a certificate. Absent for a free \u003cname\u003e.simple-host.app address.",
                      "properties": {
                        "host": {
                          "example": "_simple-host.www.example.com",
                          "type": "string"
                        },
                        "type": {
                          "example": "TXT",
                          "type": "string"
                        },
                        "value": {
                          "example": "sh-0123456789abcdef0123456789abcdef",
                          "type": "string"
                        }
                      },
                      "type": "object"
                    },
                    "domain": {
                      "example": "www.example.com",
                      "type": "string"
                    },
                    "expires_at": {
                      "description": "bound_at plus 24 hours; included only while pending and never verified",
                      "format": "date-time",
                      "type": "string"
                    },
                    "partner_domain": {
                      "description": "The domain's www / bare partner (www.\u003cdomain\u003e for a bare domain, the bare domain for www.\u003cdomain\u003e). It forwards (301) to the chosen name; the ownership record on the chosen name covers both. Absent for any other domain.",
                      "example": "example.com",
                      "type": "string"
                    },
                    "partner_note": {
                      "description": "Why the partner is not set up. Present only then.",
                      "type": "string"
                    },
                    "partner_status": {
                      "description": "pending: the chosen name has no certificate yet (the partner is added with it). live: it forwards to the chosen name. not_set_up: it is not on the certificate (partner_note says why: not pointed here, served by another site on this server, or connected to a site of its own); it is added automatically once fixed (checked every few hours).",
                      "enum": [
                        "pending",
                        "live",
                        "not_set_up"
                      ],
                      "type": "string"
                    },
                    "previous_domain": {
                      "description": "The site's earlier own address, still serving it until this domain is verified (then it redirects here). Present only while the new domain is pending.",
                      "example": "clay-studio.simple-host.app",
                      "type": "string"
                    },
                    "status": {
                      "enum": [
                        "pending",
                        "active",
                        "error"
                      ],
                      "example": "pending",
                      "type": "string"
                    },
                    "took_over_from": {
                      "description": "Previous holder as handle/site; included only on takeover of an unproven binding",
                      "example": "alice/blog",
                      "type": "string"
                    },
                    "verified_at": {
                      "description": "Set at once for a free \u003cname\u003e.simple-host.app address",
                      "format": "date-time",
                      "nullable": true,
                      "type": "string"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "Domain bound — pending DNS for a custom domain, active at once for a free \u003cname\u003e.simple-host.app address"
          },
          "400": {
            "content": {
              "application/json": {
                "examples": {
                  "invalid": {
                    "value": {
                      "code": "invalid_name",
                      "error": "the name may use lowercase letters, numbers and hyphens (not at the start or end), up to 63 characters"
                    }
                  },
                  "reserved": {
                    "value": {
                      "code": "name_reserved",
                      "error": "that name is reserved; pick another"
                    }
                  }
                },
                "schema": {
                  "properties": {
                    "code": {
                      "enum": [
                        "name_reserved",
                        "invalid_name",
                        "invalid_domain",
                        "use_address_family"
                      ],
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "Invalid domain. For a \u003cname\u003e.simple-host.app address, `code` is name_reserved or invalid_name. A wildcard (`*.\u003cdomain\u003e`) is use_address_family (connect it with POST /v1/me/address-families)."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "content": {
              "application/json": {
                "example": {
                  "code": "domain_taken",
                  "error": "domain is connected to another site"
                },
                "schema": {
                  "additionalProperties": false,
                  "properties": {
                    "code": {
                      "enum": [
                        "domain_taken",
                        "domain_releasing"
                      ],
                      "type": "string"
                    },
                    "error": {
                      "enum": [
                        "domain is connected to another site",
                        "that address is taken by another site; pick another name",
                        "this domain was just disconnected and is still being released; try again in 10 minutes"
                      ],
                      "type": "string"
                    }
                  },
                  "required": [
                    "error",
                    "code"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "domain_taken: the domain is verified (or past its ownership proof) for another site, it is under another account's address family, or the \u003cname\u003e.simple-host.app address is held by another site or is someone's handle. domain_releasing: the domain was just disconnected and the certificate issuer is still letting go of it; try again in 10 minutes."
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Give this site a nicer address (free \u003cname\u003e.simple-host.app or a custom domain)",
        "tags": [
          "Custom domains"
        ]
      }
    },
    "/v1/sites/{sitename}/domain/check": {
      "post": {
        "description": "Re-proves the site's own domain at once (its TXT ownership record holds the site's token, it resolves here and serves the site over HTTPS; the check connects only to this server's public address and never follows a redirect), instead of waiting for the background check every two minutes, then answers exactly like GET .../domain with the new status and `last_error`. A free \u003cname\u003e.simple-host.app address needs no proof and is answered as is. Owner only (API key); rate-limited per IP and per account (a few at once, then one per 30 seconds; 429 with code rate_limited).",
        "operationId": "checkSitesBySitenameDomain",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "dns": {
                      "properties": {
                        "host": {
                          "type": "string"
                        },
                        "type": {
                          "type": "string"
                        },
                        "value": {
                          "type": "string"
                        }
                      },
                      "type": "object"
                    },
                    "dns_txt": {
                      "description": "The TXT ownership record (see GET .../domain)",
                      "properties": {
                        "host": {
                          "type": "string"
                        },
                        "type": {
                          "type": "string"
                        },
                        "value": {
                          "type": "string"
                        }
                      },
                      "type": "object"
                    },
                    "domain": {
                      "example": "www.example.com",
                      "type": "string"
                    },
                    "expires_at": {
                      "format": "date-time",
                      "type": "string"
                    },
                    "last_error": {
                      "example": "resolves to 203.0.113.9, not to this server",
                      "type": "string"
                    },
                    "status": {
                      "enum": [
                        "pending",
                        "active",
                        "error"
                      ],
                      "type": "string"
                    },
                    "verified_at": {
                      "format": "date-time",
                      "nullable": true,
                      "type": "string"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "The domain binding after the check (same shape as GET .../domain)"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "content": {
              "application/json": {
                "example": {
                  "code": "no_domain",
                  "error": "this site has no domain connected"
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "No such site, or the site has no domain connected (no_domain)"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Check this site's domain again now",
        "tags": [
          "Custom domains"
        ]
      }
    },
    "/v1/sites/{sitename}/export-link": {
      "post": {
        "description": "Returns a link that downloads the same archive as `export.tar.gz` without an API key, for people who use Simple Host only through a chat app's connector (the `export_site` tool). The link is signed for this owner and this site, expires after 10 minutes, and stops working if the site is deleted or the server restarts. Anyone holding it can download the copy until then, so hand it to the person only.",
        "operationId": "createExportLink",
        "parameters": [
          {
            "$ref": "#/components/parameters/createIdempotencyKey"
          },
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "expires_at": {
                      "format": "date-time",
                      "type": "string"
                    },
                    "expires_in": {
                      "description": "Seconds until it expires (600)",
                      "type": "integer"
                    },
                    "site": {
                      "type": "string"
                    },
                    "url": {
                      "example": "https://simple-host.app/v1/export?token=...",
                      "type": "string"
                    }
                  },
                  "required": [
                    "site",
                    "url",
                    "expires_at",
                    "expires_in"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "The link"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Idempotency-Key reused with a different body (idempotency_key_reused)."
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Make a 10-minute download link for a site's export",
        "tags": [
          "Sites"
        ]
      }
    },
    "/v1/sites/{sitename}/export.tar.gz": {
      "get": {
        "description": "Streams the site's published files (`files/`), its saved JSON state (`state.json`) and its collections (`collections.json`: every list, private ones included, each entry as `{id, created_at, submitted_by, data}`; `submitted_by` only on private lists) as one archive. Exists because an event instance is destroyed when the event ends and nothing is backed up, so the person who built something can take it with them. The saved data comes first: the files usually exist somewhere else, the JSON only lives here.",
        "operationId": "exportSite",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/gzip": {
                "schema": {
                  "format": "binary",
                  "type": "string"
                }
              }
            },
            "description": "A gzipped tar archive"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Not found",
            "or not yours": null
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Download a site as a tar.gz",
        "tags": [
          "Sites"
        ]
      }
    },
    "/v1/sites/{sitename}/files": {
      "post": {
        "description": "The LLM-friendly deploy path — send every file inline in one request, no archiving. Text goes in `files`; base64-encoded binary goes in `files_base64`. `index.html` is required. Relative paths only (`..`/absolute are rejected); secret files (`.env`, `.git/*`, `id_rsa`) are dropped and script extensions (`.sh .py .php …`) are rejected. Use PUT to update an existing site (409 site_exists says so); PUT with ?create=1 creates or updates in one call. On simple-hack.app (EVENTS=hosted) only a team key (or a connector bound to a team) deploys, to its own team's site only; errors 403 no_personal_sites, 403 team_site_only, 403 team_key_scope, 401 team_key_inactive, 409 team_sites_not_ready, 409 submissions_closed, 403 team_site_taken_down.",
        "operationId": "postSitesBySitenameFiles",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/createIdempotencyKey"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FilesBody"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteWithNote"
                }
              }
            },
            "description": "Site created"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Deploy a site from inline JSON files",
        "tags": [
          "Deploy"
        ]
      },
      "put": {
        "description": "Same as POST, but for an existing site you own. Creates a new version and activates it; with ?publish=false the version is only stored (visitors keep seeing the live one) and the answer adds unpublished_version, preview_url and preview_expires_at. With ?create=1 a missing site is created instead (201, with the same checks as POST), so CI deploys in one call; without it a missing site is 404. With ?keep=1, send only changed files: other files in the live version are retained byte-for-byte, and remove_files drops listed relative paths. remove_files requires keep=1; index.html is not required in a patch. The merged version meets the ordinary file and storage caps. On simple-hack.app (EVENTS=hosted) only a team key (or a connector bound to a team) deploys, to its own team's site only; errors 403 no_personal_sites, 403 team_site_only, 403 team_key_scope, 401 team_key_inactive, 409 team_sites_not_ready, 409 submissions_closed, 403 team_site_taken_down.",
        "operationId": "putSitesBySitenameFiles",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/publish"
          },
          {
            "$ref": "#/components/parameters/create"
          },
          {
            "description": "Keep unchanged files from the live version; merged files use ordinary unpacked-file caps",
            "in": "query",
            "name": "keep",
            "schema": {
              "enum": [
                1
              ],
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FilesBody"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteWithNote"
                }
              }
            },
            "description": "Site updated"
          },
          "201": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteWithNote"
                }
              }
            },
            "description": "Site created (?create=1 and no site of that name)"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Update a site from inline JSON files",
        "tags": [
          "Deploy"
        ]
      }
    },
    "/v1/sites/{sitename}/keep": {
      "put": {
        "description": "Mark the site Keep (or clear the mark). Where the idle-site cleanup is on (simple-host.app), a site with no visits by people and no new version for 90 days gets its owner an email with a \"Keep it\" link (and a link to their page, where they can sign in and download it); 30 days later with nothing done it moves to Recently deleted (restorable for 7 days, with a link in a second email). A site marked Keep is never flagged, and marking it ends a pending warning. Sites with their own domain or claimed name are never flagged either. Owner only (API key).",
        "operationId": "putSitesBySitenameKeep",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "keep": {
                    "example": true,
                    "type": "boolean"
                  }
                },
                "required": [
                  "keep"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "keep": {
                      "type": "boolean"
                    },
                    "name": {
                      "type": "string"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "The flag as set"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Body is not {\"keep\": true|false}"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "423": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "The site is taken down by the operator"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Keep a site up for good",
        "tags": [
          "Sites"
        ]
      }
    },
    "/v1/sites/{sitename}/keep-versions": {
      "put": {
        "description": "Every deploy stores a full copy of the site as a new version. keep_versions sets how many this site keeps: N from 1 to 1000 keeps the newest N plus any older live version. 0 goes back to the account default (KEEP_VERSIONS_OVERRIDES), else this server's setting (KEEP_VERSIONS; 0 there means keep every version). Setting it removes the older versions at once, for good (their files and their rows; removed_versions names them), and every later deploy removes whatever falls outside it. The value is on the site (keep_versions in GET /v1/sites) when set. Owner only (a full API key; a deploy-only key is refused, since this deletes history). Only accounts in KEEP_VERSIONS_SELF_SET (current or old handles) may set a count when that knob is set. Others get 403 keep_versions_fixed with the plain message \"Simple Host keeps your 4 latest versions\". Hosted defaults: 4; the chhotabreak, vineetu and jot-transcribe exceptions default to 10.",
        "operationId": "putSitesBySitenameKeepVersions",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "keep_versions": {
                    "example": 5,
                    "maximum": 1000,
                    "minimum": 0,
                    "type": "integer"
                  }
                },
                "required": [
                  "keep_versions"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "active_version": {
                      "type": "integer"
                    },
                    "effective_keep_versions": {
                      "description": "What applies to this site now; 0 = every version is kept",
                      "type": "integer"
                    },
                    "keep_versions": {
                      "description": "The site's own setting; 0 = this server's",
                      "type": "integer"
                    },
                    "name": {
                      "type": "string"
                    },
                    "removed_versions": {
                      "description": "Version numbers removed by this call (empty when none)",
                      "items": {
                        "type": "integer"
                      },
                      "type": "array"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "The setting as stored, and what it removed"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Body is not {\"keep_versions\": N} with N from 0 to 1000"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "A deploy-only key (deploy_only_key), or an account whose version count is fixed (keep_versions_fixed)"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "423": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "The site is taken down by the operator"
          },
          "429": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Too many site changes from this address; try again shortly"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Set how many versions of a site are kept",
        "tags": [
          "Sites"
        ]
      }
    },
    "/v1/sites/{sitename}/lock": {
      "delete": {
        "description": "Owner only (the owner's key or connector token; a deploy-only key is refused). Every address of the site opens to everyone again. Removing a passcode the site does not have is not an error.",
        "operationId": "deleteSitesBySitenameLock",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SitePasscode"
                }
              }
            },
            "description": "The site has no passcode"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "A deploy-only key (deploy_only_key)"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Remove a site's passcode",
        "tags": [
          "Sites"
        ]
      },
      "get": {
        "description": "Owner only (the owner's key or connector token; a deploy-only key is refused). Says whether the site asks visitors for a passcode and, when it does, the passcode itself and when it was set. The passcode is stored sealed with a server key (AES-256-GCM under PASSCODE_ENC_KEY), so the owner can read it back; the admin's routes never read it. If the server's key changed, `passcode` is absent and `note` says to set a new one. Never cached (Cache-Control: no-store).",
        "operationId": "getSitesBySitenameLock",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SitePasscode"
                }
              }
            },
            "description": "The site's passcode state",
            "headers": {
              "Cache-Control": {
                "schema": {
                  "example": "no-store",
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "A deploy-only key (deploy_only_key)"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Whether a site has a passcode, and the passcode",
        "tags": [
          "Sites"
        ]
      },
      "put": {
        "description": "Owner only (the owner's key or connector token; a deploy-only key is refused). Every address of the site (its own address, the person path while its certificate is pending, a free name, a custom domain) then answers a plain \"This site is protected\" page (401, noindex, no-store; nothing of the site in it; robots.txt answers `Disallow: /`) until the visitor enters the passcode. A right passcode sets an unlock cookie for that one host (HttpOnly, SameSite=Lax, `__Host-` prefixed, about 400 days) that lasts until the passcode changes or the owner signs everyone out; `.app` and `.site` addresses each need their own unlock. Send `{\"passcode\": \"...\"}` (any characters, at least PASSCODE_MIN_LENGTH, default 6, at most 128; digits only is fine; spaces around it are trimmed) or `{\"generate\": true}` for 6 random digits. Setting the same passcode again changes nothing; a different one signs everyone out. The site leaves the person page. The owner's key, connector token and the admin key still read and write its data; previews still open. Take-down (410) and offline (503) win over the passcode. There is no end date: take the site offline or delete it instead. A passcode keeps out people who don't have it: search engines, link previews, and anyone who finds or is forwarded the link without it. Anyone given it can open the site and pass it on. It is not a login, it does not say who visited, and it does not make saved data private per person.",
        "operationId": "putSitesBySitenameLock",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "description": "Exactly one of passcode or generate",
                "properties": {
                  "generate": {
                    "description": "true: the server picks 6 random digits",
                    "type": "boolean"
                  },
                  "passcode": {
                    "description": "At least PASSCODE_MIN_LENGTH characters (default 6)",
                    "example": "482913",
                    "maxLength": 128,
                    "type": "string"
                  }
                },
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SitePasscode"
                }
              }
            },
            "description": "The passcode is set (the answer carries it)",
            "headers": {
              "Cache-Control": {
                "schema": {
                  "example": "no-store",
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Body is not {\"passcode\": \"...\"} or {\"generate\": true} (invalid_request), or the passcode is too short or too long (invalid_passcode)"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "The operator has taken the site down (site_suspended), or a deploy-only key (deploy_only_key)"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "passcodes_not_enabled (SITE_PASSCODES is off or PASSCODE_ENC_KEY is unset on this server), or passcode_needs_own_address (every site on this server shares one address, as on a small box or an event box, so an unlock would cover every site)"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Put a passcode on a whole site, or change it",
        "tags": [
          "Sites"
        ]
      }
    },
    "/v1/sites/{sitename}/lock/sign-out-everyone": {
      "post": {
        "description": "Owner only (the owner's key or connector token; a deploy-only key is refused). Ends every unlock at once, on every address: each visitor must enter the passcode again. The passcode stays the same.",
        "operationId": "postSitesBySitenameLockSignOutEveryone",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SitePasscode"
                }
              }
            },
            "description": "Everyone is signed out"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "A deploy-only key (deploy_only_key)"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "The site has no passcode (no_passcode)"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Sign every visitor out of a site with a passcode",
        "tags": [
          "Sites"
        ]
      }
    },
    "/v1/sites/{sitename}/me": {
      "get": {
        "description": "Pages call it same-origin on the site's own address — its address `https://\u003csite\u003e.\u003chandle\u003e.simple-host.app/` (only that site answers there), or its domain if it has one. Origin/Referer-gated. When the site has a connected domain and the call comes through another address, it returns signed_in false, sign_in_available false and code use_custom_domain with `domain` naming it, so the page can send the visitor to sign in there. Where sign-in is not offered (the legacy path address) the code is custom_domain_required. Checks the host, site and session expiry without extending the session. No CSRF header required. Anonymous visitors receive 200.",
        "operationId": "getVisitorMe",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "properties": {
                        "email": {
                          "description": "Latest verified OAuth identity email; users.username when no identity exists",
                          "type": "string"
                        },
                        "expires_at": {
                          "description": "Earlier of absolute and idle expiry (RFC3339)",
                          "format": "date-time",
                          "type": "string"
                        },
                        "provider": {
                          "description": "Google (more providers later); omitted when no OAuth identity exists",
                          "example": "google",
                          "type": "string"
                        },
                        "signed_in": {
                          "enum": [
                            true
                          ],
                          "type": "boolean"
                        },
                        "site_owner": {
                          "description": "Simple Host: the site's owner, signed in on one of the site's own addresses (its site host, family address or own domain). Pages there read and change saved data with owner rights; nothing else. Omitted otherwise.",
                          "enum": [
                            true
                          ],
                          "type": "boolean"
                        }
                      },
                      "required": [
                        "signed_in",
                        "expires_at"
                      ],
                      "type": "object"
                    },
                    {
                      "properties": {
                        "sign_in": {
                          "enum": [
                            "/v1/auth/oauth/providers"
                          ],
                          "type": "string"
                        },
                        "signed_in": {
                          "enum": [
                            false
                          ],
                          "type": "boolean"
                        }
                      },
                      "required": [
                        "signed_in",
                        "sign_in"
                      ],
                      "type": "object"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "custom_domain_required",
                            "use_custom_domain"
                          ],
                          "type": "string"
                        },
                        "domain": {
                          "description": "The site's bound custom domain; present only with code use_custom_domain",
                          "type": "string"
                        },
                        "sign_in_available": {
                          "enum": [
                            false
                          ],
                          "type": "boolean"
                        },
                        "signed_in": {
                          "enum": [
                            false
                          ],
                          "type": "boolean"
                        }
                      },
                      "required": [
                        "signed_in",
                        "sign_in_available",
                        "code"
                      ],
                      "type": "object"
                    }
                  ]
                }
              }
            },
            "description": "Signed-in identity or anonymous visitor",
            "headers": {
              "Cache-Control": {
                "schema": {
                  "enum": [
                    "private, no-store"
                  ],
                  "type": "string"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Internal server error"
          }
        },
        "summary": "Read the current site visitor session",
        "tags": [
          "Visitor sign-in"
        ]
      },
      "options": {
        "operationId": "optionsVisitorMe",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "204": {
            "description": "Origin allowed",
            "headers": {
              "Access-Control-Allow-Headers": {
                "schema": {
                  "enum": [
                    "Content-Type"
                  ],
                  "type": "string"
                }
              },
              "Access-Control-Allow-Methods": {
                "schema": {
                  "enum": [
                    "GET, OPTIONS"
                  ],
                  "type": "string"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatchOrSiteLocked"
          }
        },
        "summary": "Preflight for visitor session reads",
        "tags": [
          "Visitor sign-in"
        ]
      }
    },
    "/v1/sites/{sitename}/notifications": {
      "get": {
        "description": "Owner credentials required. Simple Hack and Enterprise are not offered in phase 1.",
        "operationId": "listSiteNotifications",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationList"
                }
              }
            },
            "description": "List notifications for a site"
          },
          "401": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing or invalid owner credentials."
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationError"
                }
              }
            },
            "description": "not_found, or not_offered on Simple Hack."
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "List notifications for a site",
        "tags": [
          "Notifications"
        ]
      },
      "post": {
        "description": "The owner-entered URL is accepted without an MCP verification challenge. Generates a secret unless one is supplied; the response shows it once. Optional custom header and svix-* aliases are sent only for owner-made subscriptions. MCP and REST share the site subscription cap. Simple Host only.",
        "operationId": "createNotification",
        "parameters": [
          {
            "$ref": "#/components/parameters/createIdempotencyKey"
          },
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/NotificationCreate"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationCreated"
                }
              }
            },
            "description": "Create an owner-made notification"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationError"
                }
              }
            },
            "description": "invalid_url, refused_address, invalid_events, invalid_secret, invalid_header, or storage_not_found."
          },
          "401": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing or invalid owner credentials."
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationError"
                }
              }
            },
            "description": "not_found, or not_offered on Simple Hack."
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationError"
                }
              }
            },
            "description": "too_many_notifications: the site subscription cap is reached. notifications_not_enabled: the server has no PASSCODE_ENC_KEY."
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Create an owner-made notification",
        "tags": [
          "Notifications"
        ]
      }
    },
    "/v1/sites/{sitename}/notifications/{id}": {
      "delete": {
        "description": "Owner credentials required. Simple Hack and Enterprise are not offered in phase 1.",
        "operationId": "deleteNotification",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "description": "Subscription ID from the notification list.",
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationDeleted"
                }
              }
            },
            "description": "Remove a notification"
          },
          "401": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing or invalid owner credentials."
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationError"
                }
              }
            },
            "description": "not_found, or not_offered on Simple Hack."
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Remove a notification",
        "tags": [
          "Notifications"
        ]
      },
      "patch": {
        "description": "false also resumes suspension and makes held deliveries due now. Owner credentials required. Simple Host only.",
        "operationId": "pauseNotification",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "description": "Subscription ID from the notification list.",
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/NotificationPause"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationUpdated"
                }
              }
            },
            "description": "Pause or resume a notification"
          },
          "401": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing or invalid owner credentials."
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationError"
                }
              }
            },
            "description": "not_found, or not_offered on Simple Hack."
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Pause or resume a notification",
        "tags": [
          "Notifications"
        ]
      }
    },
    "/v1/sites/{sitename}/notifications/{id}/deliveries": {
      "get": {
        "description": "Newest first, at most 200 within the configured history window (default 7 days). Pending deliveries are held while suspended. Five attempts by default; HTTP 410/413 and refused addresses are not retried. Simple Host only.",
        "operationId": "listNotificationDeliveries",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "description": "Subscription ID from the notification list.",
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationDeliveries"
                }
              }
            },
            "description": "List recent notification deliveries"
          },
          "401": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing or invalid owner credentials."
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationError"
                }
              }
            },
            "description": "not_found, or not_offered on Simple Hack."
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "List recent notification deliveries",
        "tags": [
          "Notifications"
        ]
      }
    },
    "/v1/sites/{sitename}/notifications/{id}/rotate-secret": {
      "post": {
        "description": "Shows the new secret once. Deliveries carry signatures for both secrets for 24 hours, until previous_valid_until, or drops the previous secret if it cannot be read. An MCP subscription must refresh events/subscribe with a new secret instead. Simple Host only.",
        "operationId": "rotateNotificationSecret",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "description": "Subscription ID from the notification list.",
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationRotatedSecret"
                }
              }
            },
            "description": "Rotate an owner-made signing secret"
          },
          "401": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing or invalid owner credentials."
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationError"
                }
              }
            },
            "description": "not_found, or not_offered on Simple Hack."
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationError"
                }
              }
            },
            "description": "client_secret: the client manages this MCP subscription secret. notifications_not_enabled: the server has no PASSCODE_ENC_KEY."
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Rotate an owner-made signing secret",
        "tags": [
          "Notifications"
        ]
      }
    },
    "/v1/sites/{sitename}/notifications/{id}/test": {
      "post": {
        "description": "Sends notification.test with data {v:1, site, site_url, test:true}; webhook-id starts with evt_test_. The response reports the HTTP status and result category, never the receiver body. Test sends require an unexpired owner-made subscription and do not appear in delivery history. Challenge and test sends share limits of 10/minute per owner, host, and resolved IP, with at most 10 running at once. Simple Host only.",
        "operationId": "testNotification",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "description": "Subscription ID from the notification list.",
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationTestResult"
                }
              }
            },
            "description": "Send a signed test notification"
          },
          "401": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing or invalid owner credentials."
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationError"
                }
              }
            },
            "description": "not_found, or not_offered on Simple Hack."
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationError"
                }
              }
            },
            "description": "client_subscription: test sends require an unexpired owner-made subscription."
          },
          "503": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotificationError"
                }
              }
            },
            "description": "busy: ten verification or test sends are already running."
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Send a signed test notification",
        "tags": [
          "Notifications"
        ]
      }
    },
    "/v1/sites/{sitename}/photos": {
      "post": {
        "description": "The Add photos page at /photos prepares images on the person's device. Upload 1 to 30 WebP or JPEG files as repeated photos multipart parts. The endpoint accepts X-API-Key, including the dashboard's stored owner key and account-wide deploy-only keys (not site-scoped), with the normal deploy permissions and rate limit. Visitor cookies do not authorize it. Browser origins must match the apex. Default limits are 1,000,000 bytes per photo (PHOTO_MAX_BYTES) and 2048 pixels on the longest side (PHOTO_MAX_DIMENSION). The server validates headers and container structure without decoding pixels or re-encoding. This is header-only validation, not a full decode of the compressed image. JPEG allows JFIF APP0, ICC_PROFILE APP2 (16 KB total), and Adobe APP14; comments and other APP markers are refused. WebP ICCP is limited to 16 KB; EXIF, XMP, animation, unknown chunks and trailing data are refused. ICC contents are not inspected; browser canvas output carries no profile or a standard profile. Signed-in and photo-token saves share PHOTO_MAX_CONCURRENT slots (default 3), acquired before body reads. Both entry points set a body-read deadline of 60 seconds (PHOTO_BODY_TIMEOUT_SECONDS) on the real response writer; unsupported writers continue normally. Full capacity returns 429 with error \"Busy, try again in a moment\" and Retry-After of 2 seconds, without queueing. Filenames must match ^[a-z0-9][a-z0-9-]{0,59}\\.(webp|jpg)$ and the extension must match the contents. Files go under images/, with -2, -3, and further suffixes for collisions. Every other live file is kept. One normal published version is created, with normal file quotas and retention. Hosted and small-box Simple Host only; Simple Hack returns 404.",
        "operationId": "addSitePhotos",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/createIdempotencyKey"
          }
        ],
        "requestBody": {
          "content": {
            "multipart/form-data": {
              "schema": {
                "properties": {
                  "photos": {
                    "items": {
                      "format": "binary",
                      "type": "string"
                    },
                    "maxItems": 30,
                    "minItems": 1,
                    "type": "array"
                  }
                },
                "required": [
                  "photos"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "photos": {
                      "items": {
                        "properties": {
                          "bytes": {
                            "type": "integer"
                          },
                          "height": {
                            "type": "integer"
                          },
                          "path": {
                            "type": "string"
                          },
                          "url": {
                            "format": "uri",
                            "type": "string"
                          },
                          "width": {
                            "type": "integer"
                          }
                        },
                        "required": [
                          "path",
                          "url",
                          "bytes",
                          "width",
                          "height"
                        ],
                        "type": "object"
                      },
                      "type": "array"
                    },
                    "site": {
                      "type": "string"
                    },
                    "version": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "site",
                    "version",
                    "photos"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Photos added in one new live version"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Origin or deploy permission refused"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Site not found or feature not offered"
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "A file or directory conflicts with the photo path"
          },
          "413": {
            "account": null,
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Photo",
            "or request size limit exceeded": null,
            "site": null
          },
          "429": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Deploy rate limit or photo save capacity exceeded; Retry-After advises when to retry"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Add prepared photos as one site version",
        "tags": [
          "Deploy"
        ]
      }
    },
    "/v1/sites/{sitename}/restore": {
      "post": {
        "description": "Brings a site back from Recently deleted under the same name and address, with every version, its saved data, collections and connected address, live at once.",
        "operationId": "restoreSite",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "200": {
            "as in GET /v1/sites": null,
            "description": "The restored site"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Site quota reached"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "No site with that name in Recently deleted"
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "That site is live",
            "not deleted": null
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Restore a recently deleted site",
        "tags": [
          "Sites"
        ]
      }
    },
    "/v1/sites/{sitename}/showcase": {
      "get": {
        "description": "Owner key or connector only, never deploy-only keys. Available on hosted Simple Host and small-box path installs, outside hosted events.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "order": {
                      "maximum": 1000000,
                      "minimum": 0,
                      "type": "integer"
                    },
                    "pinned": {
                      "type": "boolean"
                    },
                    "site": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "site",
                    "pinned",
                    "order"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Pin and manual order"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Deploy-only key refused"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Site not owned or deleted",
            "or showcase settings unavailable": null
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Get an owned site's showcase pin and order",
        "tags": [
          "Account"
        ]
      },
      "parameters": [
        {
          "$ref": "#/components/parameters/sitename"
        }
      ],
      "put": {
        "description": "Send pinned and/or order; omitted fields stay unchanged. Pinned sites come first, then ascending order, with creation time breaking ties. Unlisted and unavailable sites stay hidden in HTML and JSON, even when pinned. Preferences follow rename and restore. Owner key or connector only, never deploy-only keys; hosted events excluded.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "additionalProperties": false,
                "minProperties": 1,
                "properties": {
                  "order": {
                    "maximum": 1000000,
                    "minimum": 0,
                    "type": "integer"
                  },
                  "pinned": {
                    "type": "boolean"
                  }
                },
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "order": {
                      "maximum": 1000000,
                      "minimum": 0,
                      "type": "integer"
                    },
                    "pinned": {
                      "type": "boolean"
                    },
                    "site": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "site",
                    "pinned",
                    "order"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Saved pin and manual order"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing or invalid pinned/order"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Deploy-only key refused"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Site not owned or deleted",
            "or showcase settings unavailable": null
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Pin or manually order an owned site on the showcase",
        "tags": [
          "Account"
        ]
      }
    },
    "/v1/sites/{sitename}/storage/files/{name}/download-link": {
      "post": {
        "operationId": "linkStorageFile",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/createIdempotencyKey"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "path": {
                    "type": "string"
                  }
                },
                "required": [
                  "path"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StorageDownloadLink"
                }
              }
            },
            "description": "{url,expires_at}; bearer URL remains private and is rechecked when used"
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Idempotency-Key reused with a different body (idempotency_key_reused)."
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Mint a ten-minute scoped full-byte download link (owner only)",
        "tags": [
          "Storage resources"
        ]
      }
    },
    "/v1/sites/{sitename}/storage/files/{name}/objects": {
      "get": {
        "description": "Checked against the read value. Visitors get mine per item; the owner gets visitor_id. Own lists use the writer/path index with LIMIT before opening files. Temporary dot objects are excluded. Exact legacy prefixes and cursors remain accepted; cursors follow scanned metadata even if files are missing.",
        "operationId": "listStorageFiles",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "prefix",
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "after",
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "maximum": 500,
              "minimum": 1,
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "items: [{path,bytes,content_type,mine|visitor_id}], next_after cursor"
          }
        },
        "summary": "Page through durable file objects under the resource read policy",
        "tags": [
          "Storage resources"
        ]
      }
    },
    "/v1/sites/{sitename}/storage/files/{name}/objects/{path}": {
      "delete": {
        "description": "Checked against delete; own deletes only the author's file (404 otherwise); a caller who may only add gets 403 add_only.",
        "operationId": "deleteStorageFile",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeletedResponse"
                }
              }
            },
            "description": "deleted: true"
          }
        },
        "summary": "Remove one durable file under the resource's delete value",
        "tags": [
          "Storage resources"
        ]
      },
      "get": {
        "description": "Own reads return only the signed-in visitor's file; other paths return 404. The path may contain slash-separated segments. Only validated raster bytes render inline; other types are attachments with nosniff.",
        "operationId": "getStorageFile",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Raw file bytes"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "file_not_found"
          }
        },
        "summary": "Download complete file bytes under the resource read policy",
        "tags": [
          "Storage resources"
        ]
      },
      "put": {
        "description": "The server decides add (a new path, checked against add, published without replacing) or edit (an existing path, checked against edit; own edits only by the author, who stays the author). A caller who may add but not edit gets 409 file_exists. The owner's tools can always replace. File growth beyond the separate website allowance returns site_full with photo-shrinking tips. Empty files count toward SITE_STORAGE_FILES_MAX_OBJECTS (1000 per bucket by default); bucket_full is 507. New upload paths are normalized to NFC; reads and deletes accept exact legacy paths. Paths allow at most 16 segments.",
        "operationId": "putStorageFile",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/octet-stream": {
              "schema": {
                "format": "binary",
                "type": "string"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "{path,bytes,content_type}"
          },
          "507": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "site_full"
          }
        },
        "summary": "Add or replace raw file bytes under the resource's add and edit values",
        "tags": [
          "Storage resources"
        ]
      }
    },
    "/v1/sites/{sitename}/storage/kv/{name}/keys": {
      "get": {
        "description": "Own lists return only the signed-in visitor's keys. Visitors get mine true/false per item; the owner's tools and the owner signed in on the site get visitor_id.",
        "operationId": "listStorageKV",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "prefix",
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "after",
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "maximum": 500,
              "minimum": 1,
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "items: [{key,value,mine|visitor_id}], next_after cursor"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "forbidden or site_locked"
          }
        },
        "summary": "Page through JSON KV entries under the resource read value",
        "tags": [
          "Storage resources"
        ]
      }
    },
    "/v1/sites/{sitename}/storage/kv/{name}/keys/{key}": {
      "delete": {
        "description": "Checked against delete. Own deletes carry visitor_id = caller in the statement and answer 404 for another person's key; a caller who may only add gets 403 add_only.",
        "operationId": "deleteStorageKV",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "key",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeletedResponse"
                }
              }
            },
            "description": "deleted: true"
          }
        },
        "summary": "Remove one KV key under the resource's delete value",
        "tags": [
          "Storage resources"
        ]
      },
      "get": {
        "description": "Own reads return only the signed-in visitor's key; other keys return 404. Visitors get mine; the owner gets visitor_id.",
        "operationId": "getStorageKV",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "key",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{key,value}"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "key_not_found or resource_not_found"
          }
        },
        "summary": "Read one JSON value under the resource read value",
        "tags": [
          "Storage resources"
        ]
      },
      "put": {
        "description": "The server decides add (the key does not exist; checked against add, created with ON CONFLICT DO NOTHING) or edit (it exists; checked against edit, own edits carry the author check in the UPDATE and keep the original author). A caller who may add but not edit gets 409 key_exists on an existing key. The owner's tools can always replace. New keys are normalized to NFC; exact legacy names remain readable and deletable. Visitor writes use the site's own origin and X-SH-CSRF header; anonymous writes require write=anyone. Visitor writes are limited per site/IP and per site/visitor by RATE_LIMIT_STORAGE_IP and RATE_LIMIT_STORAGE_VISITOR (429); reads consume no write tokens. Writes wait SITE_STORAGE_WRITE_LOCK_WAIT_MS (default 2000) for the deploy lock before 503 storage_busy with Retry-After.",
        "operationId": "putStorageKV",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "key",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "value": {}
                },
                "required": [
                  "value"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "{key,value}"
          },
          "507": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "site_full"
          }
        },
        "summary": "Add or edit one JSON value under the resource's add and edit values",
        "tags": [
          "Storage resources"
        ]
      }
    },
    "/v1/sites/{sitename}/storage/resources": {
      "get": {
        "description": "On Simple Host each resource carries preset (public, inbox, wall, comments, records, personal, board, private, custom with based_on, or legacy), access {read,add,edit,delete}, tables (SQLite, per-table preset and access), approve_first (SQLite), and the older read, write and write_mode whenever the matrix can be expressed in them. legacy marks a resource saved before presets that keeps its old rules until the owner saves a policy on it. Simple Hack answers with read, write, write_mode and site_passcode only.",
        "operationId": "listStorageResources",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "resources: [{name,kind,preset,access:{read,add,edit,delete},tables,read,write,write_mode,site_passcode}]"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "forbidden"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "List a site's storage resources and policies (owner only)",
        "tags": [
          "Storage resources"
        ]
      }
    },
    "/v1/sites/{sitename}/storage/resources/{name}": {
      "delete": {
        "operationId": "deleteStorageResource",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeletedResponse"
                }
              }
            },
            "description": "deleted: true"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "forbidden"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Permanently delete a resource and its data (owner only)",
        "tags": [
          "Storage resources"
        ]
      },
      "put": {
        "description": "Kind is immutable. Simple Host: set preset (public, inbox, wall, comments, records, personal, board, private; default private) with optional single-action overrides read, add, edit, delete, or preset custom with all four. Each action takes nobody, owner, own, signed-in or anyone; add also takes checked (anyone who passes the Cloudflare Turnstile spam check: the comments preset, read anyone / add checked / edit owner / delete owner). The server refuses (400 invalid_access, with rule) edit or delete anyone (anonymous_change), add own (add_own), own anywhere with add anyone or checked (own_needs_sign_in), checked on read, edit or delete (checked_add_only), and edit or delete wider than read (change_wider_than_read). checked is refused on files resources, and with 400 spam_check_unavailable on a server without TURNSTILE_SITE_KEY and a secret (Simple Hack included). approve_first (SQLite, needs a checked add) holds posts added through the check: the server adds approved_at TEXT to those tables (rows already there count as approved), visitors see and reach only rows with approved_at set, and the owner approves with UPDATE ... SET approved_at = datetime('now'). Omit approve_first to keep it. SQLite: the preset is the database default; tables sets a preset per table (omit to keep the current table presets, {} to clear). Tables whose preset uses own or lets visitors add get the server-owned visitor_id TEXT column and index. The older read, write and write_mode are still accepted and translated (write anyone with full is refused as anonymous_change; omitting write_mode on update keeps the mode); do not mix them with preset fields. Switching a legacy SQLite database pages wrote with SQL to a preset that uses own needs empty tables (visitor_id_required). Owner here means the owner's tools and the owner signed in on the site's own address through visitor sign-in; nobody means the owner's tools only. Simple Hack accepts only read and write (anyone, signed-in, owner) with full mode, as before presets.",
        "operationId": "setStorageResource",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "add": {
                    "enum": [
                      "nobody",
                      "owner",
                      "signed-in",
                      "checked",
                      "anyone"
                    ],
                    "type": "string"
                  },
                  "approve_first": {
                    "type": "boolean"
                  },
                  "delete": {
                    "enum": [
                      "nobody",
                      "owner",
                      "own",
                      "signed-in"
                    ],
                    "type": "string"
                  },
                  "edit": {
                    "enum": [
                      "nobody",
                      "owner",
                      "own",
                      "signed-in"
                    ],
                    "type": "string"
                  },
                  "kind": {
                    "enum": [
                      "kv",
                      "sqlite",
                      "files"
                    ],
                    "type": "string"
                  },
                  "preset": {
                    "enum": [
                      "public",
                      "inbox",
                      "wall",
                      "comments",
                      "records",
                      "personal",
                      "board",
                      "private",
                      "custom"
                    ],
                    "type": "string"
                  },
                  "read": {
                    "enum": [
                      "nobody",
                      "owner",
                      "own",
                      "signed-in",
                      "anyone"
                    ],
                    "type": "string"
                  },
                  "site_passcode": {
                    "enum": [
                      "inherit",
                      false
                    ],
                    "type": "string"
                  },
                  "tables": {
                    "additionalProperties": {
                      "properties": {
                        "add": {
                          "type": "string"
                        },
                        "delete": {
                          "type": "string"
                        },
                        "edit": {
                          "type": "string"
                        },
                        "preset": {
                          "type": "string"
                        },
                        "read": {
                          "type": "string"
                        }
                      },
                      "type": "object"
                    },
                    "type": "object"
                  },
                  "write": {
                    "description": "older field",
                    "enum": [
                      "anyone",
                      "signed-in",
                      "owner"
                    ],
                    "translated": null,
                    "type": "string"
                  },
                  "write_mode": {
                    "description": "older field",
                    "enum": [
                      "full",
                      "add"
                    ],
                    "translated": null,
                    "type": "string"
                  }
                },
                "required": [
                  "kind"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Existing resource policy changed; the resource as listed"
          },
          "201": {
            "description": "Resource created; the resource as listed"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "invalid_access (with rule), invalid_resource, visitor_id_required"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "forbidden"
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "resource_kind_conflict, or tables_present (older fields on a database with per-table presets)"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Create a resource or set its preset (owner only)",
        "tags": [
          "Storage resources"
        ]
      }
    },
    "/v1/sites/{sitename}/storage/sqlite/{name}/execute": {
      "post": {
        "description": "Owner only on any database with a preset; visitors receive 403 fixed_routes_required. A legacy full-mode database saved before presets (and every Simple Hack database) keeps visitor SQL under its write policy. A statement that reads rows also needs read permission. Visitor writes require same-origin CSRF header. All SQLite executions share SITE_STORAGE_SQL_CONCURRENCY (0 selects CPU count); SITE_STORAGE_ACQUIRE_WAIT_MS (default 250) bounds queue waiting before 503 with Retry-After. Owner operations retain SITE_STORAGE_OWNER_TIMEOUT_MS (default 5000).",
        "operationId": "executeStorageSQLite",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/StorageSQL"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "{changes,last_insert_id}"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "forbidden"
          }
        },
        "summary": "Run one parameterized SQLite data-changing statement",
        "tags": [
          "Storage resources"
        ]
      }
    },
    "/v1/sites/{sitename}/storage/sqlite/{name}/query": {
      "post": {
        "description": "Owner only on any database with a preset; visitors receive 403 fixed_routes_required and use the table rows routes. A legacy full-mode database saved before presets (and every Simple Hack database) keeps visitor SQL under its read policy until the owner sets a preset. The SQLite engine authorizer enforces read-only access. Results are bounded, with columns and rows. All SQLite executions share SITE_STORAGE_SQL_CONCURRENCY (0 selects CPU count); SITE_STORAGE_ACQUIRE_WAIT_MS (default 250) bounds queue waiting before 503 with Retry-After. Owner operations retain SITE_STORAGE_OWNER_TIMEOUT_MS (default 5000).",
        "operationId": "queryStorageSQLite",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/StorageSQL"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "columns: [...], rows: [[...]]"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "invalid_sql",
            "invalid_params or result_too_large": null
          }
        },
        "summary": "Run one read-only parameterized SQLite statement",
        "tags": [
          "Storage resources"
        ]
      }
    },
    "/v1/sites/{sitename}/storage/sqlite/{name}/schema": {
      "post": {
        "description": "New tables whose preset uses own or lets visitors add (every table of a legacy own database) receive visitor_id TEXT and its index; changes removing the column from such a table roll back. All SQLite executions share SITE_STORAGE_SQL_CONCURRENCY (0 selects CPU count); SITE_STORAGE_ACQUIRE_WAIT_MS (default 250) bounds queue waiting before 503 with Retry-After. Owner operations retain SITE_STORAGE_OWNER_TIMEOUT_MS (default 5000).",
        "operationId": "schemaStorageSQLite",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/StorageSQL"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "changes: integer"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "forbidden"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Run one SQLite schema statement (owner only)",
        "tags": [
          "Storage resources"
        ]
      }
    },
    "/v1/sites/{sitename}/storage/sqlite/{name}/tables/{table}/rows": {
      "get": {
        "description": "Checked against the table's read value (its own preset, or the database's). Equality filters where.{column}=value (at most three, real columns only) are bound values. Visitors see their own visitor_id and null for other people's, plus mine per row; visitor_id cannot be filtered or ordered by visitors. Own reads use WHERE visitor_id = ? with the stable site-session identity. Table and order columns are checked against real schema; values are bound. Default order is an unshadowed rowid alias ascending; ordinary rowid tables only. Cursor includes order value and rowid, bound on the next query with matching order/desc. Owner sees all. No visitor SQL accepted. HEAD is a read. Visitor tables are limited to 100 columns and the complete response to SITE_STORAGE_SQL_RESULT_MAX_BYTES; visitor queries time out after one second. Reads do not lock writes or deploys.",
        "operationId": "listStorageTableRows",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "table",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "order",
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "desc",
            "schema": {
              "enum": [
                0,
                1
              ],
              "type": "integer"
            }
          },
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "default": 100,
              "maximum": 500,
              "minimum": 1,
              "type": "integer"
            }
          },
          {
            "in": "query",
            "name": "after",
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "where.{column}=value, equality, at most three",
            "explode": true,
            "in": "query",
            "name": "where",
            "schema": {
              "additionalProperties": {
                "type": "string"
              },
              "type": "object"
            },
            "style": "deepObject"
          }
        ],
        "responses": {
          "200": {
            "description": "columns, rows, mine (visitors) and opaque next_after cursor; own reads include only the signed-in visitor’s rows"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "invalid_rows: unknown table, invalid/injected column, order, cursor or scalar value"
          },
          "401": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "sign_in_required"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "forbidden, csrf_required or site_locked"
          }
        },
        "summary": "Read table rows with fixed server-built SQL",
        "tags": [
          "Storage resources"
        ]
      },
      "post": {
        "description": "Checked against the table's add value. One JSON object of real column names and scalar values. The server builds INSERT OR ABORT with bound values; visitor-supplied primary keys and rowids are refused. SQLite assigns the IDs and RETURNING supplies the result. Visitor visitor_id, created_at and updated_at are ignored; visitor_id is filled from the site session and created_at with UTC time when the columns exist. Declared SQLite foreign keys must point at a row the visitor may read under the parent table's read value (for own, a row they added), checked inside the insert transaction. Missing and unreadable parents both return 404 invalid_reference; the insert is rolled back. Optional references must be all NULL; partial composite NULLs are refused. A trigger or foreign-key action that writes another table refuses the insert (403). No visitor SQL accepted. Where add is checked (the comments preset), a caller other than the owner sends the one-time Turnstile token in X-SH-Turnstile (auth.js does it on its own); without one 403 spam_check_required, a failed, reused, expired or mismatched one 403 spam_check_failed, Cloudflare unreachable 403 spam_check_unavailable. The token must have been solved in the frame at GET /v1/turnstile/frame for this host and resource. Such a post is at most SPAM_CHECKED_POST_MAX_BYTES (413 post_too_large) with at most SPAM_CHECKED_POST_MAX_LINKS links (400 too_many_links), and RATE_LIMIT_SPAM_CHECKED_IP and RATE_LIMIT_SPAM_CHECKED_SITE apply (429). The same holds for adding a KV key.",
        "operationId": "addStorageTableRow",
        "parameters": [
          {
            "$ref": "#/components/parameters/createIdempotencyKey"
          },
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "table",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "additionalProperties": {
                  "nullable": true,
                  "oneOf": [
                    {
                      "type": "string"
                    },
                    {
                      "type": "number"
                    },
                    {
                      "type": "boolean"
                    }
                  ]
                },
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StorageRowCreated"
                }
              }
            },
            "description": "changes and last_insert_id"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "invalid_rows: unknown table, invalid/injected column, order, cursor or scalar value"
          },
          "401": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "sign_in_required"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "forbidden, csrf_required or site_locked"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "invalid_reference (parent missing or belongs to another visitor; identical response)"
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "row_conflict (generic uniqueness or constraint refusal)"
          },
          "429": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "rate_limited"
          },
          "503": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "storage_busy"
          },
          "507": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "site_full or sqlite_full: quota precheck or engine allocation failure"
          }
        },
        "summary": "Insert one table row with fixed server-built SQL",
        "tags": [
          "Storage resources"
        ]
      }
    },
    "/v1/sites/{sitename}/storage/sqlite/{name}/tables/{table}/rows/{id}": {
      "delete": {
        "description": "Checked against the table's delete value. DELETE ... WHERE rowid = ? [AND visitor_id = caller]; zero rows is 404. A foreign-key action (ON DELETE CASCADE) or trigger that writes another table refuses the delete (403).",
        "operationId": "deleteStorageTableRow",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "table",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "minimum": 1,
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeletedResponse"
                }
              }
            },
            "description": "deleted: true"
          },
          "403": {
            "add_only": null,
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "csrf_required or site_locked": null,
            "description": "forbidden"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "row_not_found"
          }
        },
        "summary": "Delete one table row with fixed server-built SQL",
        "tags": [
          "Storage resources"
        ]
      },
      "get": {
        "description": "Checked against the table's read value; own reads add visitor_id = caller, so another person's row is 404 like a missing one. Simple Host only (Simple Hack answers 404).",
        "operationId": "getStorageTableRow",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "table",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "minimum": 1,
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{columns,row,mine}"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "row_not_found"
          }
        },
        "summary": "Read one table row by id with fixed server-built SQL",
        "tags": [
          "Storage resources"
        ]
      },
      "patch": {
        "description": "Checked against the table's edit value. A JSON object of real column names and scalar values; id, primary keys, visitor_id, created_at and updated_at are refused (400 server_column). The server builds UPDATE OR ABORT ... WHERE rowid = ? [AND visitor_id = caller] and stamps updated_at when the column exists; zero rows changed is 404. Foreign keys are checked as on add. A trigger or foreign-key action that writes another table refuses the edit (403). Visitor writes need the site's own origin and X-SH-CSRF.",
        "operationId": "editStorageTableRow",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "table",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "minimum": 1,
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "additionalProperties": {
                  "nullable": true,
                  "oneOf": [
                    {
                      "type": "string"
                    },
                    {
                      "type": "number"
                    },
                    {
                      "type": "boolean"
                    }
                  ]
                },
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "{changes}"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "invalid_rows or server_column"
          },
          "401": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "sign_in_required"
          },
          "403": {
            "add_only": null,
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "csrf_required or site_locked": null,
            "description": "forbidden"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "row_not_found or invalid_reference"
          }
        },
        "summary": "Edit columns of one table row with fixed server-built SQL",
        "tags": [
          "Storage resources"
        ]
      }
    },
    "/v1/sites/{sitename}/storage/usage": {
      "get": {
        "description": "Host KV/SQLite share 10000000 bytes per website; files have a separate 10000000-byte allowance. Hack retains its 1000000-byte pool across all three kinds. The single-upload default remains 1000000 bytes. Deployed website assets and legacy saved data have separate limits. SQLite main database bytes are counted after checkpoint; transient WAL is excluded. KV key bytes count too. File buckets have at most SITE_STORAGE_FILES_MAX_OBJECTS (default 1000) committed objects, including empty files.",
        "operationId": "getSiteStorageUsage",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{used_bytes,limit_bytes,remaining_bytes,files_used_bytes,files_limit_bytes,files_remaining_bytes,breakdown:{kv_bytes,sqlite_bytes,files_bytes}}"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "forbidden"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Read this site's KV/SQLite and separate file usage (owner only)",
        "tags": [
          "Storage resources"
        ]
      }
    },
    "/v1/sites/{sitename}/storage/visitors": {
      "get": {
        "description": "Owner only (key or connector). Turns visitor_id values from this site's saved data (the visitor_id column of read-own SQLite tables; `visitor_id` on KV keys and stored files, which only the owner's reads show) into the email each person signs in with. Only people who signed in on this site as visitors resolve (recorded by the server when a visitor session starts, never read from the site's own tables; `found` false otherwise). 1 to 100 ids, comma-separated or repeated. Not on Simple Hack.",
        "operationId": "getSiteStorageVisitors",
        "parameters": [
          {
            "in": "path",
            "name": "sitename",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "visitor_id values, comma-separated (or repeat id=)",
            "in": "query",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "visitors: [{visitor_id, email?, found}]"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "invalid_visitor_id"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "forbidden (not the owner)"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Who sent a record (visitor_id to email; owner only)",
        "tags": [
          "Storage resources"
        ]
      }
    },
    "/v1/sites/{sitename}/versions": {
      "get": {
        "description": "The versions this instance still holds, newest first. An instance may be configured to keep only the last few deploys, or only the live one, in which case older versions are absent here and from disk. KEEP_VERSIONS controls it; event boxes are installed with 1.",
        "operationId": "getSitesBySitenameVersions",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "items": {
                    "properties": {
                      "created_at": {
                        "format": "date-time",
                        "type": "string"
                      },
                      "is_active": {
                        "type": "boolean"
                      },
                      "status": {
                        "description": "active: complete and live now or before; ready: stored with publish=false and never live yet",
                        "enum": [
                          "active",
                          "ready"
                        ],
                        "type": "string"
                      },
                      "version_number": {
                        "type": "integer"
                      }
                    },
                    "type": "object"
                  },
                  "type": "array"
                }
              }
            },
            "description": "Versions (newest first)"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "List a site's versions",
        "tags": [
          "Versions"
        ]
      }
    },
    "/v1/sites/{sitename}/versions/{version}/files": {
      "get": {
        "description": "Owner only. Walks the version directory; paths are relative, use forward slashes, and are sorted. Pruned versions return 404.",
        "operationId": "getSitesBySitenameVersionFiles",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/version"
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "created_at": {
                      "format": "date-time",
                      "type": "string"
                    },
                    "files": {
                      "items": {
                        "properties": {
                          "path": {
                            "example": "index.html",
                            "type": "string"
                          },
                          "size": {
                            "description": "Size in bytes",
                            "format": "int64",
                            "type": "integer"
                          }
                        },
                        "required": [
                          "path",
                          "size"
                        ],
                        "type": "object"
                      },
                      "type": "array"
                    },
                    "is_active": {
                      "type": "boolean"
                    },
                    "version_number": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "version_number",
                    "is_active",
                    "created_at",
                    "files"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Version metadata and files"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Invalid version",
            "empty site name": null,
            "or invalid file path": null
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Site not found or version not found (including pruned versions)"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "List files in a retained version",
        "tags": [
          "Versions"
        ]
      }
    },
    "/v1/sites/{sitename}/versions/{version}/files/{path}": {
      "get": {
        "description": "Owner only. Streams raw bytes with a Content-Type based on the file extension (application/octet-stream fallback; HTML uses UTF-8). Directories return 404. Paths containing '..', leading slashes, backslashes, NUL, or symlinks escaping the version directory are rejected. Pruned versions return 404.",
        "operationId": "getSitesBySitenameVersionFile",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/version"
          },
          {
            "$ref": "#/components/parameters/path"
          }
        ],
        "responses": {
          "200": {
            "content": {
              "*/*": {
                "schema": {
                  "format": "binary",
                  "type": "string"
                }
              }
            },
            "description": "Raw file bytes",
            "headers": {
              "Cache-Control": {
                "schema": {
                  "enum": [
                    "private, no-store"
                  ],
                  "type": "string"
                }
              },
              "Content-Security-Policy": {
                "schema": {
                  "enum": [
                    "sandbox"
                  ],
                  "type": "string"
                }
              },
              "X-Content-Type-Options": {
                "schema": {
                  "enum": [
                    "nosniff"
                  ],
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Invalid version",
            "empty site name": null,
            "or invalid path": null
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Site not found",
            "or file not found": null,
            "version not found": null
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Read a file from a retained version",
        "tags": [
          "Versions"
        ]
      }
    },
    "/v1/sites/{sitename}/versions/{version}/preview-link": {
      "post": {
        "description": "Owner only. Mints a link, working for anyone who has it for one hour, that shows one kept version (live, earlier, or stored with publish=false) exactly as visitors would see it, on the site's own host: https://\u003csite\u003e.\u003chandle\u003e.simple-host.app/__preview/\u003cn\u003e/\u003ctoken\u003e/ (or briefly, as a fallback while the person's certificate is not ready, \u003chandle\u003e.simple-host.app/\u003csite\u003e/__preview/...). The token is an HMAC over site, version and expiry; it stops working after an hour or a server restart. Preview pages are noindex and never cached, and saves made from them are refused (403 preview_read_only; told apart by the Referer, so a page that suppresses its Referer is not stopped: this guards against accidents, and the pages are the owner's own). Make the version live with PUT .../active-version.",
        "operationId": "postSitesBySitenameVersionPreviewLink",
        "parameters": [
          {
            "$ref": "#/components/parameters/createIdempotencyKey"
          },
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/version"
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "expires_at": {
                      "format": "date-time",
                      "type": "string"
                    },
                    "expires_in": {
                      "example": 3600,
                      "type": "integer"
                    },
                    "live": {
                      "description": "Whether visitors see this version now",
                      "type": "boolean"
                    },
                    "site": {
                      "type": "string"
                    },
                    "url": {
                      "format": "uri",
                      "type": "string"
                    },
                    "version": {
                      "type": "integer"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "The preview link"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "The operator has taken the site down (code site_suspended)"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Site or version not found (including pruned versions)"
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "No preview link (code preview_unavailable): the site has no address of its own yet, or the server gives sites none (every site on one shared address, as on a small box), which the message says"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Preview a kept version",
        "tags": [
          "Versions"
        ]
      }
    },
    "/v1/sites/{sitename}/viewers": {
      "post": {
        "description": "Owner only. Adds each email (trimmed and lower-cased; letters, digits and `._%+-` before the `@`; matched exactly, with no folding of dots or `+tags`) to the site's named viewers and sets access to `specific` in the same call. At most SITE_VIEWERS_MAX (default 50) per site. `added` lists the new ones, `already_listed` the ones that were there.",
        "operationId": "postSitesBySitenameViewers",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/createIdempotencyKey"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "emails": {
                    "example": [
                      "mom@example.com",
                      "dad@example.com"
                    ],
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "type": "array"
                  }
                },
                "required": [
                  "emails"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteAccess"
                }
              }
            },
            "description": "Who can open the site now"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "invalid_request, invalid_email (names the address), or too_many_viewers (more than SITE_VIEWERS_MAX in one call)"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "site_suspended or deploy_only_key"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "too_many_viewers (the list would pass SITE_VIEWERS_MAX), passcode_set, named_viewers_need_own_address, sign_in_unavailable or named_viewers_unavailable"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Name viewers by email, and open the site only to them",
        "tags": [
          "Sites"
        ]
      }
    },
    "/v1/sites/{sitename}/viewers/{email}": {
      "delete": {
        "description": "Owner only. The person's next request is refused. Access does not change: removing the last viewer leaves the site open only to its owner.",
        "operationId": "deleteSitesBySitenameViewersByEmail",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "in": "path",
            "name": "email",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteAccess"
                }
              }
            },
            "description": "Who can open the site now (`removed` names the email)"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "invalid_email"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "No such site, or the email is not a named viewer (viewer_not_found)"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Remove one named viewer",
        "tags": [
          "Sites"
        ]
      }
    },
    "/v1/sites/{sitename}/visibility": {
      "put": {
        "description": "Set whether this site is listed on the owner's account page at https://\u003chandle\u003e.simple-host.app/. `public` = listed there; `unlisted` = hidden from it. New sites are created `unlisted` — listing is an explicit act, not a side effect of deploying.\n\nAn unlisted site is hidden from the showcase, and from search engines unless the site has its own domain or name (a custom domain, an address-family address, or a claimed free name): every address of an unlisted site without one answers with `X-Robots-Tag: noindex, nofollow`, and its `/robots.txt` says `Disallow: /` unless the site ships its own robots.txt (the site's file wins; the header still applies). A public site, or an unlisted one with its own domain or name, gets neither, on every address. The change applies on the next request. Both values are equally reachable by anyone with the URL: `unlisted` is not privacy and must never be described as such. Owner only (API key).",
        "operationId": "putSitesBySitenameVisibility",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "visibility": {
                    "enum": [
                      "public",
                      "unlisted"
                    ],
                    "example": "unlisted",
                    "type": "string"
                  }
                },
                "required": [
                  "visibility"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Visibility updated"
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Invalid visibility value"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "security": [
          {
            "apiKey": []
          }
        ],
        "summary": "Set showcase visibility",
        "tags": [
          "Sites"
        ]
      }
    },
    "/v1/sites/{sitename}/visitor/auth": {
      "options": {
        "operationId": "optionsRequestSiteVisitorEmail",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "204": {
            "description": "Origin-authorized preflight",
            "headers": {
              "Access-Control-Allow-Headers": {
                "schema": {
                  "enum": [
                    "Content-Type, X-SH-CSRF"
                  ],
                  "type": "string"
                }
              },
              "Access-Control-Allow-Methods": {
                "schema": {
                  "enum": [
                    "POST, OPTIONS"
                  ],
                  "type": "string"
                }
              },
              "Access-Control-Max-Age": {
                "schema": {
                  "enum": [
                    "600"
                  ],
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing site name"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Origin forbidden; or site_locked (the site has a passcode: send the unlock cookie from its page, or the owner's key)"
          }
        },
        "security": [],
        "summary": "Preflight site email sign-in",
        "tags": [
          "Visitor sign-in"
        ]
      },
      "post": {
        "description": "Origin-gated; sign-in works on the site's own address (its address `https://\u003csite\u003e.\u003chandle\u003e.simple-host.app/`, or its domain if it has one), not on the apex or the legacy path address. Uses the shared account email-code limiter and site IP limiter. Normalizes and validates email, then sends a 6-digit Resend code valid for 15 minutes. Does not create the account yet.",
        "operationId": "requestSiteVisitorEmail",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "email": {
                    "example": "person@example.com",
                    "format": "email",
                    "type": "string"
                  }
                },
                "required": [
                  "email"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "202": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "email": {
                      "format": "email",
                      "type": "string"
                    },
                    "expires_in_seconds": {
                      "enum": [
                        900
                      ],
                      "type": "integer"
                    },
                    "message": {
                      "example": "We sent a 6-digit code to person@example.com.",
                      "type": "string"
                    }
                  },
                  "required": [
                    "message",
                    "email",
                    "expires_in_seconds"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Code sent; no dashboard magic link."
          },
          "400": {
            "content": {
              "application/json": {
                "example": {
                  "code": "custom_domain_required",
                  "error": "sign-in needs the site's own address"
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Invalid request, missing fields, apex host, or the legacy path address (custom_domain_required)"
          },
          "401": {
            "content": {
              "application/json": {
                "example": {
                  "code": "invalid_code",
                  "error": "That code is not right. 2 tries left.",
                  "reason": "code_wrong",
                  "tries_left": 2
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Invalid code. `code` stays invalid_code. `reason` is code_wrong, code_used_up, code_expired, or link_other_browser; `tries_left` is set on code_wrong. Verification only."
          },
          "403": {
            "content": {
              "application/json": {
                "example": {
                  "error": "forbidden"
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Origin forbidden or missing CSRF (verification)"
          },
          "404": {
            "content": {
              "application/json": {
                "example": {
                  "error": "site not found"
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Site not found"
          },
          "429": {
            "content": {
              "application/json": {
                "example": {
                  "code": "rate_limited",
                  "error": "Too many tries. Wait a minute, then try again."
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "IP or shared per-email rate limit exceeded"
          },
          "500": {
            "content": {
              "application/json": {
                "example": {
                  "error": "internal server error"
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Internal error or email delivery failed"
          }
        },
        "security": [],
        "summary": "Request a site-scoped email sign-in code",
        "tags": [
          "Visitor sign-in"
        ]
      }
    },
    "/v1/sites/{sitename}/visitor/auth/verify": {
      "options": {
        "operationId": "optionsVerifySiteVisitorEmail",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "204": {
            "description": "Origin-authorized preflight",
            "headers": {
              "Access-Control-Allow-Headers": {
                "schema": {
                  "enum": [
                    "Content-Type, X-SH-CSRF"
                  ],
                  "type": "string"
                }
              },
              "Access-Control-Allow-Methods": {
                "schema": {
                  "enum": [
                    "POST, OPTIONS"
                  ],
                  "type": "string"
                }
              },
              "Access-Control-Max-Age": {
                "schema": {
                  "enum": [
                    "600"
                  ],
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing site name"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Origin forbidden; or site_locked (the site has a passcode: send the unlock cookie from its page, or the owner's key)"
          }
        },
        "security": [],
        "summary": "Preflight site email sign-in",
        "tags": [
          "Visitor sign-in"
        ]
      },
      "post": {
        "description": "Origin-gated; sign-in works on the site's own address (its address `https://\u003csite\u003e.\u003chandle\u003e.simple-host.app/`, or its domain if it has one), not on the apex or the legacy path address. Uses the shared account email-code limiter and dedicated visitor auth IP limiter. Codes are bound to this site and the visitor purpose; dashboard codes are not accepted. Checks the latest unused, unexpired token, caps attempts, and creates the account lazily. Never returns an API key. Session has 30-day absolute and 14-day idle lifetimes. Send X-SH-CSRF: 1 is required explicitly; application/json does not satisfy this requirement.",
        "operationId": "verifySiteVisitorEmail",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/csrf"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "code": {
                    "description": "6-digit email code",
                    "example": "123456",
                    "type": "string"
                  },
                  "email": {
                    "example": "person@example.com",
                    "format": "email",
                    "type": "string"
                  }
                },
                "required": [
                  "email",
                  "code"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "email": {
                      "format": "email",
                      "type": "string"
                    },
                    "expires_at": {
                      "format": "date-time",
                      "type": "string"
                    },
                    "provider": {
                      "enum": [
                        "email"
                      ],
                      "type": "string"
                    },
                    "signed_in": {
                      "enum": [
                        true
                      ],
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "signed_in",
                    "expires_at"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Signed in; sets the existing host-only visitor session cookie. Email/provider are included; requests through the legacy path address return 400 custom_domain_required."
          },
          "400": {
            "content": {
              "application/json": {
                "example": {
                  "code": "custom_domain_required",
                  "error": "sign-in needs the site's own address"
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Invalid request, missing fields, apex host, or the legacy path address (custom_domain_required)"
          },
          "401": {
            "content": {
              "application/json": {
                "example": {
                  "code": "invalid_code",
                  "error": "That code is not right. 2 tries left.",
                  "reason": "code_wrong",
                  "tries_left": 2
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Invalid code. `code` stays invalid_code. `reason` is code_wrong, code_used_up, code_expired, or link_other_browser; `tries_left` is set on code_wrong. Verification only."
          },
          "403": {
            "content": {
              "application/json": {
                "example": {
                  "code": "csrf_required",
                  "error": "missing CSRF header"
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Origin forbidden or missing CSRF (verification)"
          },
          "404": {
            "content": {
              "application/json": {
                "example": {
                  "error": "site not found"
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Site not found"
          },
          "429": {
            "content": {
              "application/json": {
                "example": {
                  "code": "rate_limited",
                  "error": "Too many tries. Wait a minute, then try again."
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "IP or shared per-email rate limit exceeded"
          },
          "500": {
            "content": {
              "application/json": {
                "example": {
                  "error": "internal server error"
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Internal error or email delivery failed"
          }
        },
        "security": [],
        "summary": "Verify a site-scoped email sign-in code",
        "tags": [
          "Visitor sign-in"
        ]
      }
    },
    "/v1/skills": {
      "get": {
        "description": "Public catalog of the skills this server bundles (name + description + fetch URL), so any agent can search them over HTTP. No auth.",
        "operationId": "listSkills",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "count": {
                      "type": "integer"
                    },
                    "plugin": {
                      "type": "string"
                    },
                    "skills": {
                      "items": {
                        "properties": {
                          "description": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          },
                          "url": {
                            "type": "string"
                          }
                        },
                        "type": "object"
                      },
                      "type": "array"
                    },
                    "version": {
                      "type": "string"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "Skill catalog"
          }
        },
        "summary": "Skills hub catalog — discover the bundled agent skills",
        "tags": [
          "Skills"
        ]
      }
    },
    "/v1/skills/{name}": {
      "get": {
        "description": "Returns the raw SKILL.md for the named skill. Public, no auth.",
        "operationId": "getSkill",
        "parameters": [
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "description": "The skill's SKILL.md"
          },
          "404": {
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "description": "No such skill"
          }
        },
        "summary": "Fetch one skill's SKILL.md (markdown)",
        "tags": [
          "Skills"
        ]
      }
    },
    "/v1/skills/{name}/SKILL.md": {
      "get": {
        "description": "Same response as /v1/skills/{name}. Public, no auth.",
        "operationId": "getSkillMarkdown",
        "parameters": [
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "description": "The skill's SKILL.md"
          },
          "404": {
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "description": "No such skill"
          }
        },
        "summary": "Fetch one skill's SKILL.md (markdown), same as /v1/skills/{name}",
        "tags": [
          "Skills"
        ]
      }
    },
    "/v1/skills/{name}/references/{file}": {
      "get": {
        "description": "A skill's SKILL.md routes to reference documents under its references/ directory. A folder install has them on disk; install methods that fetch only SKILL.md by URL do not, so SKILL.md cites each reference by absolute URL as well and this endpoint serves it. Public, no auth.",
        "operationId": "getSkillReference",
        "parameters": [
          {
            "in": "path",
            "name": "name",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "The reference filename, e.g. backend.md",
            "in": "path",
            "name": "file",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "description": "The reference document"
          },
          "400": {
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "description": "Invalid skill or reference name"
          },
          "404": {
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "description": "No such reference"
          }
        },
        "summary": "Fetch one of a skill's reference documents (markdown)",
        "tags": [
          "Skills"
        ]
      }
    },
    "/v1/turnstile/frame": {
      "get": {
        "description": "Served on the app's own address only (PUBLIC_BASE_URL's host; 404 elsewhere, and 404 when Turnstile is not set up). A small HTML page with Cloudflare Turnstile that any origin may frame (frame-ancestors *). auth.js embeds it when a save answers spam_check_required. It starts only when its parent window posts {type: \"sh-turnstile-start\", resource}, renders the widget with action sh-post and a cdata that binds the token to the parent's origin (the browser's event.origin) and the resource, and posts {type: \"sh-turnstile-token\", token} to that origin only. auth.js accepts messages only from this frame's window and the app's origin. The server then checks the token with siteverify: success, action sh-post, this app's hostname, that cdata, a challenge under 300 seconds old, and single use. The visitor's address is never sent to Cloudflare.",
        "operationId": "turnstileFrame",
        "responses": {
          "200": {
            "description": "The frame page",
            "text/html": null
          },
          "404": {
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "description": "Not the app's own address",
            "or the spam check is off": null
          }
        },
        "summary": "The spam check frame for the comments preset",
        "tags": [
          "Visitor sign-in"
        ]
      }
    },
    "/v1/u/{handle}/showcase.json": {
      "get": {
        "description": "Exactly the public showcase projection. Unlisted, passcode-protected, offline, taken-down and deleted sites are omitted, as are sites of suspended accounts. Independent of the chosen home. Public without a key, CORS wildcard without credentials, cached 30 seconds and rate limited like public reads. Hosted events excluded.",
        "parameters": [
          {
            "in": "path",
            "name": "handle",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "bio": {
                      "type": "string"
                    },
                    "handle": {
                      "type": "string"
                    },
                    "sites": {
                      "items": {
                        "properties": {
                          "description": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          },
                          "order": {
                            "type": "integer"
                          },
                          "pinned": {
                            "type": "boolean"
                          },
                          "title": {
                            "type": "string"
                          },
                          "updated_at": {
                            "format": "date-time",
                            "type": "string"
                          },
                          "url": {
                            "format": "uri",
                            "type": "string"
                          }
                        },
                        "required": [
                          "name",
                          "url",
                          "updated_at",
                          "pinned",
                          "order"
                        ],
                        "type": "object"
                      },
                      "type": "array"
                    }
                  },
                  "required": [
                    "handle",
                    "bio",
                    "sites"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Public showcase"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Handle not found or hosted event mode"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "security": [],
        "summary": "Live public showcase feed",
        "tags": [
          "Showcase"
        ]
      }
    },
    "/v1/u/{handle}/sites/{sitename}/me": {
      "get": {
        "description": "Pages call it same-origin on the site's own address — its address `https://\u003csite\u003e.\u003chandle\u003e.simple-host.app/` (only that site answers there), or its domain if it has one. Origin/Referer-gated. When the site has a connected domain and the call comes through another address, it returns signed_in false, sign_in_available false and code use_custom_domain with `domain` naming it, so the page can send the visitor to sign in there. Where sign-in is not offered (the legacy path address) the code is custom_domain_required. Checks the host, site and session expiry without extending the session. No CSRF header required. Anonymous visitors receive 200.",
        "operationId": "getVisitorMeByHandle",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "in": "path",
            "name": "handle",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "properties": {
                        "email": {
                          "description": "Latest verified OAuth identity email; users.username when no identity exists",
                          "type": "string"
                        },
                        "expires_at": {
                          "description": "Earlier of absolute and idle expiry (RFC3339)",
                          "format": "date-time",
                          "type": "string"
                        },
                        "provider": {
                          "description": "Google (more providers later); omitted when no OAuth identity exists",
                          "example": "google",
                          "type": "string"
                        },
                        "signed_in": {
                          "enum": [
                            true
                          ],
                          "type": "boolean"
                        }
                      },
                      "required": [
                        "signed_in",
                        "expires_at"
                      ],
                      "type": "object"
                    },
                    {
                      "properties": {
                        "sign_in": {
                          "enum": [
                            "/v1/auth/oauth/providers"
                          ],
                          "type": "string"
                        },
                        "signed_in": {
                          "enum": [
                            false
                          ],
                          "type": "boolean"
                        }
                      },
                      "required": [
                        "signed_in",
                        "sign_in"
                      ],
                      "type": "object"
                    },
                    {
                      "properties": {
                        "code": {
                          "enum": [
                            "custom_domain_required",
                            "use_custom_domain"
                          ],
                          "type": "string"
                        },
                        "domain": {
                          "description": "The site's bound custom domain; present only with code use_custom_domain",
                          "type": "string"
                        },
                        "sign_in_available": {
                          "enum": [
                            false
                          ],
                          "type": "boolean"
                        },
                        "signed_in": {
                          "enum": [
                            false
                          ],
                          "type": "boolean"
                        }
                      },
                      "required": [
                        "signed_in",
                        "sign_in_available",
                        "code"
                      ],
                      "type": "object"
                    }
                  ]
                }
              }
            },
            "description": "Signed-in identity or anonymous visitor",
            "headers": {
              "Cache-Control": {
                "schema": {
                  "enum": [
                    "private, no-store"
                  ],
                  "type": "string"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Internal server error"
          }
        },
        "summary": "Read the current site visitor session",
        "tags": [
          "Visitor sign-in"
        ]
      },
      "options": {
        "operationId": "optionsVisitorMeByHandle",
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "in": "path",
            "name": "handle",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Origin allowed",
            "headers": {
              "Access-Control-Allow-Headers": {
                "schema": {
                  "enum": [
                    "Content-Type"
                  ],
                  "type": "string"
                }
              },
              "Access-Control-Allow-Methods": {
                "schema": {
                  "enum": [
                    "GET, OPTIONS"
                  ],
                  "type": "string"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatchOrSiteLocked"
          }
        },
        "summary": "Preflight for visitor session reads",
        "tags": [
          "Visitor sign-in"
        ]
      }
    },
    "/v1/u/{handle}/sites/{sitename}/visitor/auth": {
      "options": {
        "operationId": "optionsRequestUserSiteVisitorEmail",
        "parameters": [
          {
            "in": "path",
            "name": "handle",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "204": {
            "description": "Origin-authorized preflight",
            "headers": {
              "Access-Control-Allow-Headers": {
                "schema": {
                  "enum": [
                    "Content-Type, X-SH-CSRF"
                  ],
                  "type": "string"
                }
              },
              "Access-Control-Allow-Methods": {
                "schema": {
                  "enum": [
                    "POST, OPTIONS"
                  ],
                  "type": "string"
                }
              },
              "Access-Control-Max-Age": {
                "schema": {
                  "enum": [
                    "600"
                  ],
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing site name"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Origin forbidden; or site_locked (the site has a passcode: send the unlock cookie from its page, or the owner's key)"
          }
        },
        "security": [],
        "summary": "Preflight site email sign-in",
        "tags": [
          "Visitor sign-in"
        ]
      },
      "post": {
        "description": "Origin-gated; sign-in works on the site's own address (its address `https://\u003csite\u003e.\u003chandle\u003e.simple-host.app/`, or its domain if it has one), not on the apex or the legacy path address. Uses the shared account email-code limiter and site IP limiter. Normalizes and validates email, then sends a 6-digit Resend code valid for 15 minutes. Does not create the account yet.",
        "operationId": "requestUserSiteVisitorEmail",
        "parameters": [
          {
            "in": "path",
            "name": "handle",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "email": {
                    "example": "person@example.com",
                    "format": "email",
                    "type": "string"
                  }
                },
                "required": [
                  "email"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "202": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "email": {
                      "format": "email",
                      "type": "string"
                    },
                    "expires_in_seconds": {
                      "enum": [
                        900
                      ],
                      "type": "integer"
                    },
                    "message": {
                      "example": "We sent a 6-digit code to person@example.com.",
                      "type": "string"
                    }
                  },
                  "required": [
                    "message",
                    "email",
                    "expires_in_seconds"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Code sent; no dashboard magic link."
          },
          "400": {
            "content": {
              "application/json": {
                "example": {
                  "code": "custom_domain_required",
                  "error": "sign-in needs the site's own address"
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Invalid request, missing fields, apex host, or the legacy path address (custom_domain_required)"
          },
          "401": {
            "content": {
              "application/json": {
                "example": {
                  "code": "invalid_code",
                  "error": "That code is not right. 2 tries left.",
                  "reason": "code_wrong",
                  "tries_left": 2
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Invalid code. `code` stays invalid_code. `reason` is code_wrong, code_used_up, code_expired, or link_other_browser; `tries_left` is set on code_wrong. Verification only."
          },
          "403": {
            "content": {
              "application/json": {
                "example": {
                  "error": "forbidden"
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Origin forbidden or missing CSRF (verification)"
          },
          "404": {
            "content": {
              "application/json": {
                "example": {
                  "error": "site not found"
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Site not found"
          },
          "429": {
            "content": {
              "application/json": {
                "example": {
                  "code": "rate_limited",
                  "error": "Too many tries. Wait a minute, then try again."
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "IP or shared per-email rate limit exceeded"
          },
          "500": {
            "content": {
              "application/json": {
                "example": {
                  "error": "internal server error"
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Internal error or email delivery failed"
          }
        },
        "security": [],
        "summary": "Request a site-scoped email sign-in code",
        "tags": [
          "Visitor sign-in"
        ]
      }
    },
    "/v1/u/{handle}/sites/{sitename}/visitor/auth/verify": {
      "options": {
        "operationId": "optionsVerifyUserSiteVisitorEmail",
        "parameters": [
          {
            "in": "path",
            "name": "handle",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "204": {
            "description": "Origin-authorized preflight",
            "headers": {
              "Access-Control-Allow-Headers": {
                "schema": {
                  "enum": [
                    "Content-Type, X-SH-CSRF"
                  ],
                  "type": "string"
                }
              },
              "Access-Control-Allow-Methods": {
                "schema": {
                  "enum": [
                    "POST, OPTIONS"
                  ],
                  "type": "string"
                }
              },
              "Access-Control-Max-Age": {
                "schema": {
                  "enum": [
                    "600"
                  ],
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing site name"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Origin forbidden; or site_locked (the site has a passcode: send the unlock cookie from its page, or the owner's key)"
          }
        },
        "security": [],
        "summary": "Preflight site email sign-in",
        "tags": [
          "Visitor sign-in"
        ]
      },
      "post": {
        "description": "Origin-gated; sign-in works on the site's own address (its address `https://\u003csite\u003e.\u003chandle\u003e.simple-host.app/`, or its domain if it has one), not on the apex or the legacy path address. Uses the shared account email-code limiter and dedicated visitor auth IP limiter. Codes are bound to this site and the visitor purpose; dashboard codes are not accepted. Checks the latest unused, unexpired token, caps attempts, and creates the account lazily. Never returns an API key. Session has 30-day absolute and 14-day idle lifetimes. Send X-SH-CSRF: 1 is required explicitly; application/json does not satisfy this requirement.",
        "operationId": "verifyUserSiteVisitorEmail",
        "parameters": [
          {
            "in": "path",
            "name": "handle",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/csrf"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "properties": {
                  "code": {
                    "description": "6-digit email code",
                    "example": "123456",
                    "type": "string"
                  },
                  "email": {
                    "example": "person@example.com",
                    "format": "email",
                    "type": "string"
                  }
                },
                "required": [
                  "email",
                  "code"
                ],
                "type": "object"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "email": {
                      "format": "email",
                      "type": "string"
                    },
                    "expires_at": {
                      "format": "date-time",
                      "type": "string"
                    },
                    "provider": {
                      "enum": [
                        "email"
                      ],
                      "type": "string"
                    },
                    "signed_in": {
                      "enum": [
                        true
                      ],
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "signed_in",
                    "expires_at"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Signed in; sets the existing host-only visitor session cookie. Email/provider are included; requests through the legacy path address return 400 custom_domain_required."
          },
          "400": {
            "content": {
              "application/json": {
                "example": {
                  "code": "custom_domain_required",
                  "error": "sign-in needs the site's own address"
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Invalid request, missing fields, apex host, or the legacy path address (custom_domain_required)"
          },
          "401": {
            "content": {
              "application/json": {
                "example": {
                  "code": "invalid_code",
                  "error": "That code is not right. 2 tries left.",
                  "reason": "code_wrong",
                  "tries_left": 2
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Invalid code. `code` stays invalid_code. `reason` is code_wrong, code_used_up, code_expired, or link_other_browser; `tries_left` is set on code_wrong. Verification only."
          },
          "403": {
            "content": {
              "application/json": {
                "example": {
                  "code": "csrf_required",
                  "error": "missing CSRF header"
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Origin forbidden or missing CSRF (verification)"
          },
          "404": {
            "content": {
              "application/json": {
                "example": {
                  "error": "site not found"
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Site not found"
          },
          "429": {
            "content": {
              "application/json": {
                "example": {
                  "code": "rate_limited",
                  "error": "Too many tries. Wait a minute, then try again."
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "IP or shared per-email rate limit exceeded"
          },
          "500": {
            "content": {
              "application/json": {
                "example": {
                  "error": "internal server error"
                },
                "schema": {
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    },
                    "reason": {
                      "type": "string"
                    },
                    "tries_left": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "error"
                  ],
                  "type": "object"
                }
              }
            },
            "description": "Internal error or email delivery failed"
          }
        },
        "security": [],
        "summary": "Verify a site-scoped email sign-in code",
        "tags": [
          "Visitor sign-in"
        ]
      }
    },
    "/v1/uploads/{token}": {
      "post": {
        "description": "Use the private link returned by create_upload_link with archive true from a shell (the default on Simple Hack). Works once for one site for 15 minutes, including failed uploads. Sign-in is checked again at upload. Links stop working when the person signs out, disconnects the app or their sign-in expires. Creates the site if missing, else publishes a new version, public at its address on success. Never share the link or put it in a web page. Usual deploy and scope checks apply.",
        "operationId": "postUploadLink",
        "parameters": [
          {
            "in": "path",
            "name": "token",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/gzip": {
              "schema": {
                "format": "binary",
                "type": "string"
              }
            },
            "application/zip": {
              "schema": {
                "format": "binary",
                "type": "string"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteWithNote"
                }
              }
            },
            "description": "Site updated"
          },
          "201": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteWithNote"
                }
              }
            },
            "description": "Site created"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "example": {
                  "code": "upload_link_signed_out",
                  "error": "this upload link's sign-in has ended (signed out, disconnected or expired); sign in again and ask your agent for a new link"
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Sign-in ended, or deploy scope or team rules refused the upload"
          },
          "404": {
            "content": {
              "application/json": {
                "example": {
                  "code": "upload_link_invalid",
                  "error": "this upload link is not valid (it works once, for 15 minutes); ask your agent for a new one"
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Unknown, used or expired link"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "security": [],
        "summary": "Publish an archive through a one-time connector upload link",
        "tags": [
          "Deploy"
        ]
      },
      "put": {
        "description": "Use the private link returned by create_upload_link with archive true from a shell (the default on Simple Hack). Works once for one site for 15 minutes, including failed uploads. Sign-in is checked again at upload. Links stop working when the person signs out, disconnects the app or their sign-in expires. Creates the site if missing, else publishes a new version, public at its address on success. Never share the link or put it in a web page. Usual deploy and scope checks apply.",
        "operationId": "putUploadLink",
        "parameters": [
          {
            "in": "path",
            "name": "token",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/gzip": {
              "schema": {
                "format": "binary",
                "type": "string"
              }
            },
            "application/zip": {
              "schema": {
                "format": "binary",
                "type": "string"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteWithNote"
                }
              }
            },
            "description": "Site updated"
          },
          "201": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteWithNote"
                }
              }
            },
            "description": "Site created"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "example": {
                  "code": "upload_link_signed_out",
                  "error": "this upload link's sign-in has ended (signed out, disconnected or expired); sign in again and ask your agent for a new link"
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Sign-in ended, or deploy scope or team rules refused the upload"
          },
          "404": {
            "content": {
              "application/json": {
                "example": {
                  "code": "upload_link_invalid",
                  "error": "this upload link is not valid (it works once, for 15 minutes); ask your agent for a new one"
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Unknown, used or expired link"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "security": [],
        "summary": "Publish an archive through a one-time connector upload link",
        "tags": [
          "Deploy"
        ]
      }
    },
    "/v1/visitor/establish": {
      "get": {
        "description": "One-time hop on the site's own address (its `\u003csite\u003e.\u003chandle\u003e` host or domain). Consumes `once`, host-matches, requires the `__Host-sh_vnonce` cookie set by `GET /v1/visitor/oauth/{provider}` in this browser (its hash must match the one bound to the code; the code is burned on any failure), sets `__Host-sh_vsess` (or `sh_vsess` on local HTTP), clears the nonce cookie, then 302s to the stored `return_to` with no query mutation. Never sets a cookie on the apex or the legacy path address. Failures are HTML, no cookie, no redirect.",
        "operationId": "establishVisitor",
        "parameters": [
          {
            "in": "query",
            "name": "once",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Cookie set; redirect to the stored return_to",
            "headers": {
              "Location": {
                "schema": {
                  "format": "uri",
                  "type": "string"
                }
              },
              "Set-Cookie": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "description": "Bad, replayed, expired, host-mismatch, missing or wrong nonce cookie, or apex Host"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "summary": "Establish the visitor session cookie",
        "tags": [
          "Visitor sign-in"
        ]
      }
    },
    "/v1/visitor/logout": {
      "post": {
        "description": "Deletes the session named by the visitor cookie and clears the cookie. Requires `X-SH-CSRF: 1` or `Content-Type: application/json` (not a simple form post).",
        "operationId": "logoutVisitor",
        "parameters": [
          {
            "in": "header",
            "name": "X-SH-CSRF",
            "required": false,
            "schema": {
              "enum": [
                "1"
              ],
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Session deleted and cookie cleared"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "code": {
                      "example": "csrf_required",
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    }
                  },
                  "type": "object"
                }
              }
            },
            "description": "Missing CSRF header (and not a JSON request)"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "summary": "Log out this visitor session",
        "tags": [
          "Visitor sign-in"
        ]
      }
    },
    "/v1/visitor/oauth/{provider}": {
      "get": {
        "description": "Visitor sign-in start, served on the site's own address (its `\u003csite\u003e.\u003chandle\u003e` host, person address, claimed name or bound domain). `return_to` must be a page on this same host that belongs to the site served here. Sets a short-lived host-only nonce cookie (`__Host-sh_vnonce`, HttpOnly, Secure, SameSite=Lax, 10 minutes; `sh_vnonce` on local HTTP), stores only its SHA-256 with the OAuth state, and 302s to the provider with PKCE S256. `GET /v1/visitor/establish` later sets the session only in the browser holding that nonce, so a sign-in finished elsewhere cannot be completed in another browser (login CSRF). auth.js `SH.signIn()` navigates here.",
        "operationId": "startVisitorOAuth",
        "parameters": [
          {
            "in": "path",
            "name": "provider",
            "required": true,
            "schema": {
              "enum": [
                "google",
                "github",
                "oidc"
              ],
              "type": "string"
            }
          },
          {
            "description": "Absolute URL of a page on this host to return to after sign-in",
            "in": "query",
            "name": "return_to",
            "required": true,
            "schema": {
              "format": "uri",
              "type": "string"
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Nonce cookie set; redirect to the identity provider",
            "headers": {
              "Location": {
                "schema": {
                  "format": "uri",
                  "type": "string"
                }
              },
              "Set-Cookie": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "content": {
              "application/json": {
                "example": {
                  "error": "invalid return_to"
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing return_to, or one that is not a page of the site on this host"
          },
          "404": {
            "content": {
              "application/json": {
                "example": {
                  "error": "not found"
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Unknown or disabled provider"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "summary": "Start visitor sign-in on the site's own host",
        "tags": [
          "Visitor sign-in"
        ]
      }
    }
  },
  "servers": [
    {
      "description": "Production API (agents, with an API key). Pages call the same /v1 routes same-origin on their own address.",
      "url": "https://simple-host.app"
    }
  ],
  "tags": [
    {
      "description": "Account sign-in by emailed code or Google, for Simple Host account owners and the agents working for them. It ends in an API key. It is never for a site's visitors: these routes answer only on the app's own address (403 account_auth_unavailable elsewhere), and an API key never belongs in a page. A page signs its visitors in with the Visitor sign-in routes through auth.js.",
      "name": "Auth"
    },
    {
      "description": "Account profile, credentials and connected apps.",
      "name": "Account"
    },
    {
      "description": "List, inspect and manage websites.",
      "name": "Sites"
    },
    {
      "description": "Publish inline JSON files or a tar.gz/zip archive.",
      "name": "Deploy"
    },
    {
      "description": "Inspect retained deployments, preview and roll back.",
      "name": "Versions"
    },
    {
      "description": "Owner-scoped website traffic.",
      "name": "Analytics"
    },
    {
      "description": "Claim a free address or connect a custom domain.",
      "name": "Custom domains"
    },
    {
      "description": "Connect a wildcard address family for your sites.",
      "name": "Address families"
    },
    {
      "description": "Owner-declared KV, SQLite and durable file resources.",
      "name": "Storage resources"
    },
    {
      "description": "Signed HTTPS notifications for visitor storage writes. Simple Host phase 1 only.",
      "name": "Notifications"
    },
    {
      "description": "Site-scoped browser sessions for website visitors (customers, guests, members), on the site's own address, through https://simple-host.app/auth.js (SH.mount, SH.requireSignIn, SH.me, SH.signOut). The only sign-in a page ever uses. Never returns an API key.",
      "name": "Visitor sign-in"
    },
    {
      "description": "Discover and fetch this server’s agent skills and references.",
      "name": "Skills"
    },
    {
      "description": "Public feed of a person’s showcased sites.",
      "name": "Showcase"
    },
    {
      "description": "Service health and readiness.",
      "name": "Health"
    }
  ]
}