{
  "openapi": "3.1.0",
  "info": {
    "title": "ship.page",
    "version": "1.0.0",
    "description": "Throw HTML at an API, get an unguessable URL back. Anonymous deploys need no auth; account routes accept an sp_ API key or a Firebase ID token as a Bearer credential. Docs: https://ship.page/docs — agents start at https://ship.page/docs/agents. MCP server: POST https://ship.page/mcp.",
    "contact": { "name": "Bitgate, Inc.", "url": "https://ship.page", "email": "abuse@bitgate.com" }
  },
  "servers": [{ "url": "https://ship.page" }],
  "security": [],
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "An sp_… API key (minted via POST /keys or the dashboard) or a Firebase ID token."
      }
    },
    "schemas": {
      "ClientConfig": {
        "type": "object",
        "required": ["serve_domain"],
        "properties": { "serve_domain": { "type": "string", "example": "ship.place" } }
      },
      "Error": {
        "type": "object",
        "required": ["error", "code"],
        "properties": {
          "error": { "type": "string", "description": "Human-readable message." },
          "code": {
            "type": "string",
            "description": "Machine-stable error code — parse this, not `error`.",
            "enum": [
              "bad_request",
              "invalid_ttl",
              "ttl_too_long",
              "invalid_email",
              "invalid_name",
              "invalid_plan",
              "invalid_zip",
              "invalid_claim_token",
              "file_cap_exceeded",
              "zip_too_large",
              "body_too_large",
              "decompression_cap",
              "auth_required",
              "subscription_required",
              "not_found",
              "method_not_allowed",
              "already_claimed",
              "claim_conflict",
              "restore_conflict",
              "sub_taken",
              "drop_expired",
              "claim_window_closed",
              "drop_purged",
              "rate_limited",
              "backend_unavailable",
              "internal_error",
              "expired",
              "too_large",
              "unsupported",
              "busy",
              "hash_mismatch",
              "storage_full",
              "unknown_blob",
              "draft_changed",
              "content_changed",
              "publish_in_progress",
              "publish_incomplete",
              "blocked",
              "missing",
              "already_live"
            ]
          },
          "hint": { "type": "string", "description": "Human-directed guidance. Agents should relay this to their user verbatim." },
          "upgrade_url": { "type": "string", "format": "uri", "description": "Present when a plan upgrade resolves the failure." },
          "docs_url": { "type": "string", "format": "uri", "description": "Relevant docs page, when one applies." }
        }
      },
      "DeployResponse": {
        "type": "object",
        "required": ["slug", "url", "files", "plan", "expires_at", "password_protected"],
        "properties": {
          "slug": { "type": "string", "example": "vast-juice-c2dse" },
          "url": { "type": "string", "format": "uri", "example": "https://vast-juice-c2dse.ship.place/" },
          "files": { "type": "array", "items": { "type": "string" } },
          "plan": { "type": "string", "enum": ["free", "pro", "team"] },
          "expires_at": { "type": ["string", "null"], "format": "date-time", "description": "Null for drops that never expire (subscribers)." },
          "name": { "type": "string", "description": "Present when ?name= was used." },
          "replaced": { "type": "boolean", "description": "Present when ?name= was used; true if an existing named drop was replaced." },
          "password_protected": { "type": "boolean", "description": "Whether visitors must unlock the drop with a password." },
          "claim_token": {
            "type": "string",
            "pattern": "^spc_[a-z0-9]{24}$",
            "description": "One-time claim token, present only on anonymous deploys and shown exactly once. Anyone holding it can attach the drop to an account via POST /drops/{slug}/claim."
          },
          "claim_url": {
            "type": "string",
            "format": "uri",
            "description": "Present on anonymous API and MCP deploys: a browser link that saves the drop to whichever account opens it. It carries the claim token, so keep it private."
          },
          "next_step": {
            "type": "string",
            "description": "Present with claim_url: one sentence on keeping the page online, written to be passed on to the person the page was made for."
          },
          "claim_email": {
            "type": "string",
            "format": "email",
            "description": "Present when ?email= was accepted on an anonymous deploy; the claim link was mailed to this address."
          }
        }
      },
      "AppendResponse": {
        "type": "object",
        "required": ["slug", "url", "files", "total_files", "plan", "expires_at"],
        "properties": {
          "slug": { "type": "string" },
          "url": { "type": "string", "format": "uri" },
          "files": { "type": "array", "items": { "type": "string" }, "description": "Files added by this request." },
          "total_files": { "type": "integer", "description": "Total files in the drop after appending (cap 10,000)." },
          "plan": { "type": "string", "enum": ["free", "pro", "team"] },
          "expires_at": { "type": ["string", "null"], "format": "date-time" }
        }
      },
      "PasswordBody": {
        "type": "object",
        "required": ["password"],
        "properties": {
          "password": {
            "type": "string",
            "minLength": 8,
            "maxLength": 128,
            "writeOnly": true,
            "description": "8-128 Unicode characters, at most 512 UTF-8 bytes. Never put passwords in URLs."
          }
        }
      },
      "PasswordResponse": {
        "type": "object",
        "required": ["slug", "password_protected"],
        "properties": {
          "slug": { "type": "string" },
          "password_protected": { "type": "boolean" }
        }
      },
      "FilesBody": {
        "type": "object",
        "required": ["files"],
        "properties": {
          "files": {
            "type": "object",
            "description": "Path → content map. Values are a string, or { encoding: \"base64\", content: string } for binary.",
            "additionalProperties": {
              "oneOf": [
                { "type": "string" },
                {
                  "type": "object",
                  "required": ["encoding", "content"],
                  "properties": {
                    "encoding": { "type": "string", "const": "base64" },
                    "content": { "type": "string" }
                  }
                }
              ]
            }
          }
        }
      },
      "Drop": {
        "type": "object",
        "properties": {
          "slug": { "type": "string" },
          "url": { "type": "string", "format": "uri" },
          "name": { "type": ["string", "null"] },
          "file_count": { "type": "integer" },
          "redeploy_count": { "type": "integer" },
          "password_protected": { "type": "boolean" },
          "thumb": { "type": ["string", "null"], "format": "uri", "description": "Screenshot of the drop's root page (1280x800 WebP), taken a few minutes after each deploy. Null until captured, and for password-protected drops." },
          "created_at": { "type": "string", "format": "date-time" },
          "updated_at": { "type": "string", "format": "date-time" },
          "expires_at": { "type": ["string", "null"], "format": "date-time" },
          "source": { "type": ["string", "null"], "enum": ["landing", "dashboard", "api", "mcp", "chatgpt", "tools", "css-to-tailwind", "tailwind-to-css", "growth-verify", null], "description": "Creation channel, self-reported via the x-ship-source header. chatgpt marks pages published from the ship.page ChatGPT plugin." }
        }
      },
      "EditorFile": {
        "type": "object",
        "required": ["path", "bytes", "sha256", "mime"],
        "properties": {
          "path": { "type": "string", "example": "css/site.css" },
          "bytes": { "type": "integer" },
          "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
          "mime": { "type": "string" }
        }
      },
      "EditorDraft": {
        "type": "object",
        "required": ["draft_rev", "updated_at", "files"],
        "properties": {
          "draft_rev": { "type": "integer", "description": "Send it back on the next draft write; it changes on every write." },
          "updated_at": { "type": "string", "format": "date-time" },
          "files": { "type": "array", "items": { "$ref": "#/components/schemas/EditorFile" } }
        }
      },
      "EditorTree": {
        "type": "object",
        "required": ["slug", "content_rev", "cap", "live", "draft", "pending", "imported"],
        "properties": {
          "slug": { "type": "string" },
          "content_rev": { "type": "integer", "description": "Changes on every publish, from the editor or the API. Publish with it as base_content_rev." },
          "cap": { "type": "object", "properties": { "files": { "type": "integer", "example": 200 }, "bytes": { "type": "integer", "example": 20971520 } } },
          "live": {
            "type": "object",
            "required": ["revision_id", "number", "bytes", "files"],
            "properties": {
              "revision_id": { "type": "string" },
              "number": { "type": "integer" },
              "bytes": { "type": "integer" },
              "files": { "type": "array", "items": { "$ref": "#/components/schemas/EditorFile" } }
            }
          },
          "draft": { "oneOf": [{ "$ref": "#/components/schemas/EditorDraft" }, { "type": "null" }] },
          "pending": { "type": "boolean", "description": "A publish is still being written out." },
          "imported": { "type": "boolean", "description": "True when this call imported the drop into the editor." }
        }
      },
      "EditorRevision": {
        "type": "object",
        "required": ["id", "number", "title", "source", "created_at", "bytes", "file_count", "live", "changes"],
        "properties": {
          "id": { "type": "string" },
          "number": { "type": "integer" },
          "title": { "type": ["string", "null"] },
          "source": { "type": "string", "enum": ["editor", "api", "mcp", "restore", "import"] },
          "created_at": { "type": "string", "format": "date-time" },
          "bytes": { "type": "integer" },
          "file_count": { "type": "integer" },
          "live": { "type": "boolean" },
          "changes": {
            "type": "object",
            "properties": { "added": { "type": "integer" }, "modified": { "type": "integer" }, "deleted": { "type": "integer" } }
          }
        }
      },
      "DropDetail": {
        "type": "object",
        "required": ["slug", "name", "url", "file_count", "bytes", "redeploy_count", "created_at", "updated_at", "expires_at", "source", "password_protected", "blocked", "thumb", "history", "live_version", "version_count"],
        "properties": {
          "slug": { "type": "string" },
          "name": { "type": ["string", "null"] },
          "url": { "type": "string", "format": "uri" },
          "file_count": { "type": "integer" },
          "bytes": { "type": ["integer", "null"], "description": "Total size of the live files; null for drops without version history." },
          "redeploy_count": { "type": "integer" },
          "created_at": { "type": "string", "format": "date-time" },
          "updated_at": { "type": "string", "format": "date-time" },
          "expires_at": { "type": ["string", "null"], "format": "date-time" },
          "source": { "type": ["string", "null"], "description": "Creation channel, self-reported via the x-ship-source header." },
          "password_protected": { "type": "boolean" },
          "blocked": { "type": "boolean", "description": "Taken down: its files can't be downloaded and its versions can't be restored." },
          "thumb": { "type": ["string", "null"], "format": "uri", "description": "Screenshot of the drop's root page (1280x800 WebP). Null until captured, and for password-protected or taken-down drops." },
          "history": { "type": "boolean", "description": "The drop keeps versions: true once it was opened in or published through the site editor." },
          "live_version": {
            "oneOf": [
              {
                "type": "object",
                "required": ["id", "number", "title", "source", "created_at"],
                "properties": {
                  "id": { "type": "string" },
                  "number": { "type": "integer" },
                  "title": { "type": ["string", "null"] },
                  "source": { "type": "string", "enum": ["editor", "api", "mcp", "restore", "import"] },
                  "created_at": { "type": "string", "format": "date-time" }
                }
              },
              { "type": "null" }
            ]
          },
          "version_count": { "type": "integer", "description": "Published versions kept: the live one plus earlier ones. 0 without history." }
        }
      },
      "DropContentFile": {
        "type": "object",
        "required": ["path", "bytes", "mime"],
        "properties": { "path": { "type": "string" }, "bytes": { "type": "integer" }, "mime": { "type": "string" } }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Bad request — unparseable body, bad paths, invalid ttl/name, per-request file cap, invalid zip.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Unauthorized": {
        "description": "Missing or invalid credential.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "PaymentRequired": {
        "description": "Subscriber-gated feature on a free account. The body's `hint` and `upgrade_url` tell the user how to upgrade.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "NotFound": {
        "description": "Not found (also returned for resources owned by someone else).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Conflict": {
        "description": "Conflict — vanity sub taken or already owned.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "PayloadTooLarge": {
        "description": "Payload too large — a non-zip body over 10 MB, a zip over the plan cap or past its 4× decompression ceiling, or the 10,000-file drop total.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Unavailable": {
        "description": "Required backend unavailable. Password creation and rotation fail closed when billing cannot be verified; retry later.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "RateLimited": {
        "description": "Rate limited. Back off per the Retry-After header (60s).",
        "headers": {
          "Retry-After": { "schema": { "type": "integer", "example": 60 }, "description": "Seconds to wait before retrying." }
        },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    }
  },
  "paths": {
    "/deploy": {
      "post": {
        "operationId": "deploy",
        "summary": "Deploy a drop (anonymous allowed)",
        "description": "Body format is picked by Content-Type: `text/html` (or any non-JSON/non-zip type) stores the raw body as index.html; `application/json` expects a files map; `application/zip` unpacks an archive (single root dir auto-stripped). Limits: 100 files/request anonymous+free, 10,000 paid; zip 25 MB free / 100 MB paid (4× decompression cap); non-zip bodies 10 MB. Rate: 10 deploys/min per IP anonymous and free, 120/min per account on paid plans; 429 responses carry Retry-After: 60. To password-protect a drop, send X-Ship-Password with encodeURIComponent(password) and an active paid Pro/Team credential. Never send a password in the query string or files body. Omitting the header on a live named redeploy preserves protection; recreating an expired or purged named drop requires sending the password again. A top-level JSON password property is rejected. The /_ship/ namespace is reserved and cannot be uploaded; an empty header is invalid. Protected reads return 401 until the visitor unlocks in a browser and are never cached. Password creation/rotation returns 503 if billing cannot be verified.",
        "security": [{}, { "bearerAuth": [] }],
        "parameters": [
          {
            "name": "X-Ship-Password",
            "in": "header",
            "schema": { "type": "string", "writeOnly": true },
            "description": "Optional encodeURIComponent(password). Decoded password: 8-128 Unicode characters, at most 512 UTF-8 bytes. Requires an active paid Pro/Team account. Omit to preserve protection on a live named redeploy; use DELETE /drops/{slug}/password to remove it."
          },
          {
            "name": "ttl",
            "in": "query",
            "schema": { "type": "integer", "minimum": 60 },
            "description": "Drop lifetime in seconds. Anonymous default/max: 2592000 (30 days). Signed-in free default/max: 7776000 (90 days). Subscribers may set any value and default to no expiry."
          },
          {
            "name": "name",
            "in": "query",
            "schema": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,40}$" },
            "description": "Named drop (subscribers only). Stable alias scoped to the account; redeploying the same name replaces content and keeps the URL."
          },
          {
            "name": "email",
            "in": "query",
            "schema": { "type": "string", "format": "email", "maxLength": 254 },
            "description": "Anonymous deploys only: the one-time claim link is mailed to this address, so the drop stays recoverable if the claim_token from the response is lost. Echoed back as claim_email. Ignored for authenticated deploys."
          },
          {
            "name": "agent",
            "in": "query",
            "schema": { "type": "string", "maxLength": 64 },
            "description": "Name of the AI agent or app publishing the drop, e.g. chatgpt, claude, cursor. Stored trimmed and lowercased; empty counts as not sent. AI agents: this is required; e.g. ChatGPT, Claude, etc."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "text/html": { "schema": { "type": "string" }, "example": "<h1>hello</h1>" },
            "application/json": { "schema": { "$ref": "#/components/schemas/FilesBody" } },
            "application/zip": { "schema": { "type": "string", "format": "binary" } }
          }
        },
        "responses": {
          "200": {
            "description": "Drop deployed.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeployResponse" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "413": { "$ref": "#/components/responses/PayloadTooLarge" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/deploy/{slug}": {
      "post": {
        "operationId": "appendToDrop",
        "summary": "Append files to an existing drop (subscribers)",
        "description": "Adds files to a drop you own, up to 10,000 files per drop in total. Same body formats as POST /deploy. Existing password protection is preserved. Password input via query or X-Ship-Password is rejected; use the owner password endpoints to rotate it.",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "text/html": { "schema": { "type": "string" } },
            "application/json": { "schema": { "$ref": "#/components/schemas/FilesBody" } },
            "application/zip": { "schema": { "type": "string", "format": "binary" } }
          }
        },
        "responses": {
          "200": {
            "description": "Files appended.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AppendResponse" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "413": { "$ref": "#/components/responses/PayloadTooLarge" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/config": {
      "get": {
        "operationId": "getConfig",
        "summary": "Public client configuration",
        "description": "Returns the currently active serve domain. The dashboard builds displayed drop URLs from this instead of hardcoding a domain; responses are cacheable (max-age 60s).",
        "responses": {
          "200": {
            "description": "Active client configuration.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ClientConfig" } } }
          }
        }
      }
    },
    "/drops": {
      "get": {
        "operationId": "listDrops",
        "summary": "List your drops",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 500, "default": 100 } },
          { "name": "offset", "in": "query", "schema": { "type": "integer", "minimum": 0, "default": 0 } }
        ],
        "responses": {
          "200": {
            "description": "Drops, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["total", "drops"],
                  "properties": {
                    "total": { "type": "integer" },
                    "drops": { "type": "array", "items": { "$ref": "#/components/schemas/Drop" } }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/drops/{slug}": {
      "get": {
        "operationId": "getDrop",
        "summary": "View one drop",
        "description": "Everything the dashboard shows about a drop you own, including its live version when it keeps history.",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": { "description": "The drop.", "content": { "application/json": { "schema": { "type": "object", "required": ["drop"], "properties": { "drop": { "$ref": "#/components/schemas/DropDetail" } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      },
      "delete": {
        "operationId": "deleteDrop",
        "summary": "Delete a drop permanently",
        "description": "Purges files and the index entry. Deleted drops can stay edge-cached for up to 60s.",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "Drop deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["deleted"],
                  "properties": { "deleted": { "type": "string" } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/drops/{slug}/password": {
      "parameters": [
        { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
      ],
      "put": {
        "operationId": "setDropPassword",
        "summary": "Set or rotate a drop password (Pro/Team)",
        "description": "Owner-only; requires an active paid Pro/Team subscription. Billing degradation returns 503 without changing protection. Passwords are never returned. Changing access cannot recall content already downloaded or copied.",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PasswordBody" } } }
        },
        "responses": {
          "200": {
            "description": "Password set or rotated.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PasswordResponse" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      },
      "delete": {
        "operationId": "removeDropPassword",
        "summary": "Remove a drop password",
        "description": "Owner-only, including after a paid plan lapses. Removes the gate so anyone with the URL can read the drop. No request body. Cancellation alone does not remove existing protection.",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": {
            "description": "Password removed; password_protected is false.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PasswordResponse" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/_ship/password": {
      "servers": [{
        "url": "https://{slug}.{domain}",
        "variables": {
          "slug": { "default": "your-drop", "description": "Use the slug from the deploy URL." },
          "domain": { "default": "ship.place", "description": "Use the serve domain from the deploy URL." }
        }
      }],
      "post": {
        "operationId": "unlockDrop",
        "summary": "Unlock a protected drop in the visitor's browser",
        "description": "Called on the drop's own HTTPS origin, not ship.page. The Origin header must match that exact origin. Submit JSON {password}; never put it in a URL. Success sets a host-only session cookie valid for 12 hours. Protected pages and assets require this cookie and bypass caching. An account API key does not replace the visitor cookie.",
        "parameters": [
          { "name": "Origin", "in": "header", "required": true, "schema": { "type": "string", "format": "uri" }, "description": "The exact HTTPS origin of the drop, with no path." }
        ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PasswordBody" } } }
        },
        "responses": {
          "200": {
            "description": "Unlocked. No password is returned; keep the cookie for subsequent requests to this host.",
            "headers": {
              "Set-Cookie": { "schema": { "type": "string" }, "description": "Host-only visitor session, valid for 12 hours." },
              "Cache-Control": { "schema": { "type": "string" }, "description": "No-store." }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "description": "Password not accepted; the drop remains locked." },
          "403": { "description": "Origin does not match the drop's exact origin." },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/drops/{slug}/stats": {
      "get": {
        "operationId": "getDropStats",
        "summary": "View stats for a drop",
        "description": "Pageview stats for a drop you own. Bots, crawlers and previews in the ship.page dashboard are excluded; views count page loads of HTML pages, not raw asset requests. Free accounts only get the view count (access: locked). Pro (access: pro) adds the last 24 hours and 7 days, a 90-day daily series and top referrers, top pages and top countries. Pass days to narrow the daily series and the breakdowns; total, last_24h and last_7d always cover everything retained. Pageviews are retained for 90 days.",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } },
          { "name": "days", "in": "query", "required": false, "description": "Pro only. Last N days for the daily series and the breakdowns, clamped to 1..window_days. Defaults to window_days.", "schema": { "type": "integer", "minimum": 1, "maximum": 90 } }
        ],
        "responses": {
          "200": {
            "description": "Drop stats.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["slug", "access", "total"],
                  "properties": {
                    "slug": { "type": "string" },
                    "access": { "type": "string", "enum": ["locked", "pro"], "description": "locked on free accounts, which only get slug and total. pro adds every other field." },
                    "total": { "type": "integer", "description": "All-time views within the 90-day retention window." },
                    "last_24h": { "type": "integer", "description": "Pro only." },
                    "last_7d": { "type": "integer", "description": "Pro only." },
                    "window_days": { "type": "integer", "description": "Pro only. History window of the daily series: 90." },
                    "days": { "type": "integer", "description": "Pro only. Days covered by daily and the breakdowns: the requested days, clamped to 1..window_days." },
                    "daily": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": ["day", "views"],
                        "properties": {
                          "day": { "type": "string", "description": "YYYY-MM-DD (UTC)" },
                          "views": { "type": "integer" }
                        }
                      }
                    },
                    "top_referrers": {
                      "type": "array",
                      "description": "Paid plans only. Up to 5 entries; 'direct' means no referrer.",
                      "items": {
                        "type": "object",
                        "required": ["label", "views"],
                        "properties": { "label": { "type": "string" }, "views": { "type": "integer" } }
                      }
                    },
                    "top_paths": {
                      "type": "array",
                      "description": "Paid plans only. Up to 5 entries.",
                      "items": {
                        "type": "object",
                        "required": ["label", "views"],
                        "properties": { "label": { "type": "string" }, "views": { "type": "integer" } }
                      }
                    },
                    "top_countries": {
                      "type": "array",
                      "description": "Paid plans only. Up to 5 entries; label is an uppercase ISO 3166-1 alpha-2 code. Views without a known country are left out.",
                      "items": {
                        "type": "object",
                        "required": ["label", "views"],
                        "properties": { "label": { "type": "string" }, "views": { "type": "integer" } }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/drops/{slug}/contents": {
      "parameters": [{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }],
      "get": {
        "operationId": "listDropContents",
        "summary": "List a drop's files",
        "description": "Every file the drop serves, sorted by path, with its size and MIME type. Also works for expired and taken-down drops, for whatever is still stored.",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": { "description": "Files.", "content": { "application/json": { "schema": { "type": "object", "required": ["files", "bytes"], "properties": { "files": { "type": "array", "items": { "$ref": "#/components/schemas/DropContentFile" } }, "bytes": { "type": "integer", "description": "Total size of the files." } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/drops/{slug}/contents/{path}": {
      "parameters": [
        { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } },
        { "name": "path", "in": "path", "required": true, "description": "File path; URL-encode each segment and keep the slashes.", "schema": { "type": "string" } }
      ],
      "get": {
        "operationId": "getDropFile",
        "summary": "Download one file of a drop",
        "description": "Returns the raw bytes as an attachment (text files as text/plain, everything else as application/octet-stream), never rendered. X-Ship-Mime carries the type the drop serves the file with.",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": { "description": "File bytes.", "headers": { "X-Ship-Mime": { "schema": { "type": "string" }, "description": "MIME type the drop serves this file with." } }, "content": { "application/octet-stream": { "schema": { "type": "string", "format": "binary" } }, "text/plain": { "schema": { "type": "string" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "451": { "description": "The drop was taken down (blocked).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/drops/{slug}/archive": {
      "parameters": [{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }],
      "get": {
        "operationId": "downloadDropArchive",
        "summary": "Download a drop as a zip",
        "description": "Streams every file of the drop into a zip with the same paths, ready to deploy again.",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": { "description": "Zip archive, sent as an attachment named after the slug.", "content": { "application/zip": { "schema": { "type": "string", "format": "binary" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "description": "Not found, or the drop has no files left (not_found).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "451": { "description": "The drop was taken down (blocked).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/drops/{slug}/versions": {
      "parameters": [{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }],
      "get": {
        "operationId": "listDropVersions",
        "summary": "List a drop's versions",
        "description": "The 20 most recent published versions, newest first. Empty for drops without version history; listing never opens the drop in the editor.",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": { "description": "Versions.", "content": { "application/json": { "schema": { "type": "object", "required": ["versions"], "properties": { "versions": { "type": "array", "items": { "$ref": "#/components/schemas/EditorRevision" } } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/drops/{slug}/versions/{id}/restore": {
      "parameters": [{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }, { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
      "post": {
        "operationId": "restoreDropVersion",
        "summary": "Make a past version live again (Pro/Team)",
        "description": "Publishes the version's files as a new live version with source restore. The expiry and any open editor draft stay as they are. Retrying with the same idempotency_key returns the first result; when the version's files equal the live ones, the live version comes back unchanged.",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "type": "object", "required": ["idempotency_key"], "properties": { "idempotency_key": { "type": "string", "format": "uuid" } } } } }
        },
        "responses": {
          "200": { "description": "The live version.", "content": { "application/json": { "schema": { "type": "object", "required": ["version", "url"], "properties": { "version": { "type": "object", "required": ["id", "number"], "properties": { "id": { "type": "string" }, "number": { "type": "integer" } } }, "url": { "type": "string", "format": "uri" } } } } } },
          "409": { "description": "already_live: this version is the live one. busy: another publish is going live, retry after Retry-After. missing: some of the version's files are no longer stored.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "404": { "description": "Not found, also for drops without version history and versions that were never live or are no longer kept.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "410": { "description": "The drop has expired (expired).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "451": { "description": "The drop was taken down (blocked).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/drops/{slug}/claim": {
      "post": {
        "operationId": "claimDrop",
        "summary": "Claim an anonymously deployed drop",
        "description": "Attaches an anonymous drop to the authenticated account using the one-time claim token (spc_…) returned at deploy time. The token remains usable while the drop is live and for the 30-day private recovery window after expiry. A successful claim gives the drop a 90-day signed-in-free lifetime. Re-claiming a drop you already own returns 200.",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["claim_token"],
                "properties": {
                  "claim_token": { "type": "string", "description": "The spc_… token returned by the anonymous deploy." }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Drop claimed (or already owned by the caller).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["slug", "claimed", "url"],
                  "properties": {
                    "slug": { "type": "string" },
                    "claimed": { "type": "boolean" },
                    "restored": { "type": "boolean", "description": "True when the claim revived an expired drop." },
                    "expires_at": { "type": ["string", "null"], "format": "date-time" },
                    "url": { "type": "string", "format": "uri" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "description": "Invalid claim token.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "description": "Drop state changed during the claim.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "410": { "description": "The 30-day private claim window has closed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/drops/{slug}/restore": {
      "post": {
        "operationId": "restoreDrop",
        "summary": "Restore an expired drop you own",
        "description": "Brings back an expired owned drop while its files are retained, up to 90 days past expiry. Free accounts get a fresh 90-day lifetime; subscribers get the drop's expiry removed. A 200 with `restored: false` means the drop was never expired.",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "Drop restored (or was never expired).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["slug", "restored", "expires_at", "url"],
                  "properties": {
                    "slug": { "type": "string" },
                    "restored": { "type": "boolean" },
                    "expires_at": { "type": ["string", "null"], "format": "date-time" },
                    "url": { "type": "string", "format": "uri" }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "description": "Drop state changed concurrently — retry.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "410": { "description": "The drop's files have been purged (drop_purged).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/drops/{slug}/tree": {
      "parameters": [{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }],
      "get": {
        "operationId": "openEditor",
        "summary": "Open a drop in the site editor (Pro/Team)",
        "description": "Owner-only; requires an active Pro/Team subscription, like every editor route. Call this first: the first call imports the drop's live files (up to 200 files and 20 MB in total) and starts its revision history. Publishing through the API afterwards still works and shows up as a revision.",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": { "description": "Live files, your private draft and the content_rev to publish against.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EditorTree" } } } },
          "409": { "description": "busy: an upload is still landing, retry after `retry-after` seconds. too_large / unsupported: the drop doesn't fit the editor; keep deploying it through the API.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "410": { "description": "The drop has expired (expired).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/drops/{slug}/files/{path}": {
      "parameters": [
        { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } },
        { "name": "path", "in": "path", "required": true, "description": "File path; URL-encode each segment and keep the slashes.", "schema": { "type": "string" } },
        { "name": "rev", "in": "query", "required": false, "description": "live (default), draft, or a revision id.", "schema": { "type": "string" } }
      ],
      "get": {
        "operationId": "getEditorFile",
        "summary": "Download one file of the live site, the draft or a revision",
        "description": "Returns the raw bytes as an attachment (text files as text/plain, everything else as application/octet-stream), never rendered.",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": { "description": "File bytes.", "content": { "application/octet-stream": { "schema": { "type": "string", "format": "binary" } }, "text/plain": { "schema": { "type": "string" } } } },
          "409": { "description": "busy: open the drop with GET /drops/{slug}/tree first.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "410": { "description": "The drop has expired (expired).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/drops/{slug}/blobs/{sha256}": {
      "parameters": [
        { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } },
        { "name": "sha256", "in": "path", "required": true, "description": "Lowercase hex SHA-256 of the body.", "schema": { "type": "string", "pattern": "^[a-f0-9]{64}$" } }
      ],
      "put": {
        "operationId": "uploadEditorBlob",
        "summary": "Upload file contents for a draft",
        "description": "Content-addressed: upload each new or changed file's bytes once, then reference its hash from PUT /drops/{slug}/draft. Up to 10 MB per file; uploading a hash the drop already has is a no-op.",
        "security": [{ "bearerAuth": [] }],
        "requestBody": { "required": true, "content": { "application/octet-stream": { "schema": { "type": "string", "format": "binary" } } } },
        "responses": {
          "200": { "description": "Already stored.", "content": { "application/json": { "schema": { "type": "object", "properties": { "sha256": { "type": "string" }, "bytes": { "type": "integer" } } } } } },
          "201": { "description": "Stored.", "content": { "application/json": { "schema": { "type": "object", "properties": { "sha256": { "type": "string" }, "bytes": { "type": "integer" } } } } } },
          "400": { "description": "hash_mismatch: the body doesn't hash to the path's sha256.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "409": { "description": "busy: open the drop with GET /drops/{slug}/tree first.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "413": { "description": "too_large: over 10 MB.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "507": { "description": "storage_full: this drop's or account's editor storage is full.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "410": { "description": "The drop has expired (expired).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/drops/{slug}/draft": {
      "parameters": [{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }],
      "put": {
        "operationId": "saveEditorDraft",
        "summary": "Save the private draft",
        "description": "Replaces the whole draft file list. Compare-and-swap: send the draft_rev you last saw, or 0 to start a draft. Drafts are never served; publish to go live.",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["draft_rev", "files"],
                "properties": {
                  "draft_rev": { "type": "integer" },
                  "files": {
                    "type": "array",
                    "maxItems": 200,
                    "items": { "type": "object", "required": ["path", "sha256"], "properties": { "path": { "type": "string" }, "sha256": { "type": "string" } } }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Draft saved.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EditorDraft" } } } },
          "400": { "description": "bad_request (bad path) or unknown_blob: upload the hashes listed in `missing`, then retry.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "409": { "description": "draft_changed: the draft changed elsewhere. The body's `draft` is the server copy (null when it was published or discarded).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "413": { "description": "too_large: over 200 files or 20 MB.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "410": { "description": "The drop has expired (expired).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      },
      "delete": {
        "operationId": "discardEditorDraft",
        "summary": "Discard the draft",
        "description": "Discards the draft only if nobody saved since the draft_rev you pass.",
        "security": [{ "bearerAuth": [] }],
        "parameters": [{ "name": "draft_rev", "in": "query", "required": true, "description": "The draft_rev you are discarding.", "schema": { "type": "integer", "minimum": 1 } }],
        "responses": {
          "200": { "description": "Draft discarded.", "content": { "application/json": { "schema": { "type": "object", "properties": { "draft": { "type": "null" } } } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "409": { "description": "draft_changed: the draft changed elsewhere. The body's `draft` is the server copy (null when it was published or discarded).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "410": { "description": "The drop has expired (expired).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/drops/{slug}/publish": {
      "parameters": [{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }],
      "post": {
        "operationId": "publishEditorDraft",
        "summary": "Publish the draft",
        "description": "Makes the draft live at the drop's URL, or with as_new: true publishes it to a new link and keeps this drop unchanged. Safe to retry: reuse the same idempotency_key until you get a 200 or a 4xx.",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["draft_rev", "base_content_rev", "idempotency_key"],
                "properties": {
                  "draft_rev": { "type": "integer" },
                  "base_content_rev": { "type": "integer", "description": "content_rev from GET /drops/{slug}/tree." },
                  "title": { "type": ["string", "null"], "description": "Shown in the revision history; trimmed to 120 characters." },
                  "idempotency_key": { "type": "string", "format": "uuid" },
                  "as_new": { "type": "boolean", "default": false }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Published.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["slug", "url", "revision", "content_rev", "as_new"],
                  "properties": {
                    "slug": { "type": "string" },
                    "url": { "type": "string", "format": "uri" },
                    "revision": { "type": "object", "properties": { "id": { "type": "string" }, "number": { "type": "integer" } } },
                    "content_rev": { "type": "integer" },
                    "as_new": { "type": "boolean" }
                  }
                }
              }
            }
          },
          "400": { "description": "bad_request or unknown_blob: upload the hashes listed in `missing`, then retry.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "409": { "description": "content_changed: the site changed since the draft started (body has the new content_rev). draft_changed: the draft moved on. publish_in_progress: retry the same idempotency_key in 5 s.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "413": { "description": "too_large: over 200 files or 20 MB.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "410": { "description": "The drop has expired (expired).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "description": "publish_incomplete or backend_unavailable: retry the same idempotency_key in 5 s.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/drops/{slug}/revisions": {
      "parameters": [{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }],
      "get": {
        "operationId": "listEditorRevisions",
        "summary": "List published revisions",
        "description": "The 20 most recent published revisions, newest first, from the editor or the API.",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": { "description": "Revisions.", "content": { "application/json": { "schema": { "type": "object", "properties": { "revisions": { "type": "array", "items": { "$ref": "#/components/schemas/EditorRevision" } } } } } } },
          "409": { "description": "busy: open the drop with GET /drops/{slug}/tree first.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "410": { "description": "The drop has expired (expired).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/drops/{slug}/revisions/{id}": {
      "parameters": [{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }, { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
      "get": {
        "operationId": "getEditorRevision",
        "summary": "Get one revision with its file list",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": {
            "description": "Revision and files.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "revision": { "$ref": "#/components/schemas/EditorRevision" },
                    "files": { "type": "array", "items": { "$ref": "#/components/schemas/EditorFile" } }
                  }
                }
              }
            }
          },
          "409": { "description": "busy: open the drop with GET /drops/{slug}/tree first.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "410": { "description": "The drop has expired (expired).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/drops/{slug}/revisions/{id}/restore": {
      "parameters": [{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }, { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
      "post": {
        "operationId": "restoreEditorRevision",
        "summary": "Load a revision into the draft",
        "description": "Replaces the draft with the revision's files; nothing goes live until you publish. Send the current draft_rev (0 when there is no draft).",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "type": "object", "required": ["draft_rev"], "properties": { "draft_rev": { "type": "integer" } } } } }
        },
        "responses": {
          "200": { "description": "The new draft.", "content": { "application/json": { "schema": { "type": "object", "properties": { "draft": { "$ref": "#/components/schemas/EditorDraft" } } } } } },
          "409": { "description": "draft_changed: the draft changed elsewhere. The body's `draft` is the server copy (null when it was published or discarded).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "410": { "description": "The drop has expired (expired).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/keys": {
      "post": {
        "operationId": "mintKey",
        "summary": "Mint an sp_ API key",
        "description": "Requires a Firebase ID token (sign in at ship.page). The key is shown once — store it.",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "201": {
            "description": "Key minted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["accountId", "apiKey", "keyId", "note"],
                  "properties": {
                    "accountId": { "type": "string" },
                    "apiKey": { "type": "string", "description": "sp_… key, shown once." },
                    "keyId": { "type": "string", "description": "SHA-256 of the key." },
                    "note": { "type": "string" }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/billing/me": {
      "get": {
        "operationId": "billingMe",
        "summary": "Plan and subscription state",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": {
            "description": "Entitlements for the account.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["accountId", "plan", "activeSubscription"],
                  "properties": {
                    "accountId": { "type": "string" },
                    "plan": { "type": "string", "enum": ["free", "pro", "team"] },
                    "activeSubscription": { "type": "boolean" },
                    "currentPeriodEnd": { "type": ["string", "null"], "format": "date-time" },
                    "cancelAt": { "type": ["string", "null"], "format": "date-time" },
                    "portalAvailable": { "type": "boolean" }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/billing/checkout": {
      "post": {
        "operationId": "billingCheckout",
        "summary": "Create a Stripe checkout session",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "plan": { "type": "string", "enum": ["pro", "team"], "default": "pro" }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect the user to `url`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["url", "alreadySubscribed"],
                  "properties": {
                    "url": { "type": "string", "format": "uri" },
                    "alreadySubscribed": { "type": "boolean" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/billing/portal": {
      "post": {
        "operationId": "billingPortal",
        "summary": "Create a Stripe customer portal session",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": {
            "description": "Redirect the user to `url`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["url"],
                  "properties": { "url": { "type": "string", "format": "uri" } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/vanity": {
      "get": {
        "operationId": "getVanity",
        "summary": "Get the account's vanity subdomain",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": {
            "description": "The claimed sub, or null.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["sub"],
                  "properties": { "sub": { "type": ["string", "null"] } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      },
      "post": {
        "operationId": "claimVanity",
        "summary": "Claim a vanity subdomain (Team plan)",
        "description": "3–20 chars of [a-z0-9-], no double hyphens, one per account, first come first served. Named drops then serve at https://<sub>--<name>.ship.place/.",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["sub"],
                "properties": { "sub": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$" } }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Sub claimed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["sub", "serve_base"],
                  "properties": {
                    "sub": { "type": "string" },
                    "serve_base": { "type": "string", "example": "https://acme--<name>.ship.place/" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "409": { "$ref": "#/components/responses/Conflict" }
        }
      }
    },
    "/mcp": {
      "post": {
        "operationId": "mcp",
        "summary": "MCP server (JSON-RPC 2.0, stateless)",
        "description": "Streamable-HTTP-free, stateless MCP endpoint (spec 2026-07-28). Methods: `server/discover`, `tools/list`, `tools/call`, plus legacy `initialize`. Tools: `deploy_html`, `deploy_files`, `append_files`, `list_drops`, `claim_drop`, `restore_drop`, `delete_drop`, `set_drop_password`, `remove_drop_password`, `get_limits`, `get_account`. Auth is optional pass-through: send `Authorization: Bearer sp_…` to act as an account, omit for anonymous deploys. deploy_html and deploy_files accept an optional password string for active paid Pro/Team accounts; it is sent as the encoded X-Ship-Password header, never a query parameter. set_drop_password takes slug + password; remove_drop_password takes slug and works even after a plan lapses. Client config: `\"ship.page\": { \"url\": \"https://ship.page/mcp\" }`.",
        "security": [{}, { "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["jsonrpc", "method", "id"],
                "properties": {
                  "jsonrpc": { "type": "string", "const": "2.0" },
                  "id": { "oneOf": [{ "type": "string" }, { "type": "integer" }] },
                  "method": { "type": "string", "enum": ["initialize", "server/discover", "tools/list", "tools/call"] },
                  "params": { "type": "object" }
                }
              },
              "example": {
                "jsonrpc": "2.0",
                "id": 1,
                "method": "tools/call",
                "params": { "name": "deploy_html", "arguments": { "html": "<h1>hello</h1>" } }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "JSON-RPC 2.0 response envelope (`result` or `error`).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["jsonrpc", "id"],
                  "properties": {
                    "jsonrpc": { "type": "string", "const": "2.0" },
                    "id": { "oneOf": [{ "type": "string" }, { "type": "integer" }, { "type": "null" }] },
                    "result": {},
                    "error": {
                      "type": "object",
                      "properties": {
                        "code": { "type": "integer" },
                        "message": { "type": "string" },
                        "data": {}
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}
