{
  "openapi": "3.0.3",
  "info": {
    "title": "PitchStation Websites API",
    "version": "2026-07-15",
    "description": "Programmatic website creation for API tenants: generate a site from a content model, run the customer/compliance approval loop, publish to a VPS with its own domain + TLS, read aggregate visitor metrics, and tear down on churn. Authentication: Personal Access Token (pst_…) as `Authorization: Bearer`. Correlate objects with your own `external_ref` (unique per account, replay-safe) and `metadata` (≤20 scalar keys, ≤2KB, echoed in webhooks, never rendered). Webhook events: website.generated, website.submitted, website.approved, website.changes_requested, website.published, website.unpublished — HMAC-signed like invoice events."
  },
  "servers": [
    {
      "url": "https://www.pitchstation.ai"
    }
  ],
  "components": {
    "securitySchemes": {
      "pat": {
        "type": "http",
        "scheme": "bearer",
        "description": "Personal Access Token (pst_…)"
      }
    },
    "schemas": {
      "SiteCreate": {
        "type": "object",
        "required": [
          "assets"
        ],
        "properties": {
          "content": {
            "type": "object",
            "description": "The site content model. Required: meta.company_name. Notable meta fields: lang (zh-Hant|zh-Hans|en|es — localizes the site chrome), template (visual template id; server-enforced when your account has a template lock), hero_layout (diamond|full), layout (classic|editorial|showcase|monolith|atelier|sidenav|narrative — page structure, composes with every template; layouts are never locked), section_order (array of unique section ids reordering the middle sections — hero first and contact last are pinned; 400 BAD_SECTION_ORDER on unknown/duplicate ids), stats_animate (false disables the stat count-up), metrics (false disables the visitor beacon). Sections: hero, about, facts, services, products, testimonials, video, team, positioning, advantages, values, vision, mission, roadmap, insights, faq, contact. products[]: {image?, name, desc?, price?, link?, link_label?} — structured photo cards with an optional outbound link (https only); product images are recompressed server-side like assets. testimonials[]: {quote (≤400 chars), name, role?, avatar?} — max 8 rendered. video: {url, title?} — YouTube or Vimeo only (400 BAD_VIDEO_URL otherwise); rendered as a click-to-load facade, the provider iframe is only created when a visitor clicks play. Section-level variants: services_layout (cards|rows|index), products_layout (grid|spotlight), team_layout (cards|roster). Additional blocks: gallery[] {image, caption?} (masonry + click lightbox, ≤24), pricing[] {name, price, period?, desc?, features[], highlight?, link?} (≤4 tiers), clients[] {image?, name} (grayscale logo wall, ≤18), cta_banner {title, body?, button_label?, button_url?} (full-width action band; https links only). All images embedded in content are recompressed server-side. E-commerce wave: feature_rows[] {image?, tag?, title, body} (alternating feature sections, ≤8); badges[] {icon, label, sub?} trust strip (icons: shield|truck|rotate|certificate|lock|gem|wrench|star|handshake, ≤6); products[] additionally take specs[] {label,value} (≤14 — a comparison table auto-derives when ≥2 products share ≥3 labels; products_compare:false opts out), options[] {name, price?} display-only variant chips, price_was (struck-through compare-at; luxe templates suppress), collection (groups the grid), image_hover (second photo on hover); gallery_layout (masonry|plates); announcement {text, link?, until?} top bar (until = ISO date, hidden client-side after); contact.chat {channel: whatsapp|telegram|line|wechat, id, prefill?} → floating chat button + per-product \"Ask about this\" deep links (WeChat renders a copy-ID popover). Templates now include retail verticals volt (3C carbon) and maison (jewelry ivory). GET /api/website/templates also returns presets[] (config bundles: electronics, jewelry). Fashion wave: contact.social[] {platform: instagram|tiktok|facebook|youtube|x|xiaohongshu|linkedin, url} visible icon rows in Contact + footer (unknown platform → globe icon); products[].tag corner ribbon (≤18 chars, author-supplied); size_guide {title?, note?, columns[≤8], rows[][≤12]} measurement table (section id sizeguide, reorderable); gallery[].links [{label, url}] \"Shop the look\" actions (≤4/look); announcement.countdown:true renders a live timer to `until`; marquee {text ≤120} scrolling ticker under the hero (static under prefers-reduced-motion). Templates now include runway (fashion editorial monochrome); presets[] gained fashion."
          },
          "assets": {
            "type": "object",
            "description": "logo (required, data URI or base64), hero, materials[] ({image, caption, section}) — photos are recompressed server-side (≤1600px) before the 6MB embed cap."
          },
          "deckId": {
            "type": "string",
            "description": "Regenerate an existing site in place (same URL, same analytics)."
          },
          "external_ref": {
            "type": "string",
            "maxLength": 128,
            "description": "Your key for this site (e.g. agent-12345). Unique per account; charset A-Za-z0-9._:/-. Replaying a create with a known ref returns the existing site with replayed:true — sites are mutable, use update to change them."
          },
          "metadata": {
            "type": "object",
            "description": "≤20 scalar keys, ≤2KB serialized. Echoed in every webhook; never rendered into the site."
          },
          "brief": {
            "type": "string",
            "minLength": 40,
            "maxLength": 24000,
            "description": "TI-2: a plain-text company description — the server AI-drafts the full content model from it, then renders. Mutually exclusive with content (400 BAD_SOURCE if both). Replay via external_ref is checked BEFORE drafting, so idempotent retries cost no AI quota. Response gains drafted:true, the drafted content echo, and extraction warnings[]."
          },
          "lang": {
            "type": "string",
            "enum": [
              "zh-Hant",
              "zh-Hans",
              "en",
              "es"
            ],
            "description": "Language hint for brief drafting — sets meta.lang/locale deterministically."
          },
          "meta_overrides": {
            "type": "object",
            "properties": {
              "template": {
                "type": "string"
              },
              "layout": {
                "type": "string"
              }
            },
            "description": "With brief: template/layout ids (from /api/website/templates) applied to the drafted model. Unknown ids: 400 BAD_META_OVERRIDES."
          },
          "preset": {
            "type": "string",
            "enum": [
              "electronics",
              "jewelry",
              "fashion",
              "agent"
            ],
            "description": "PF-1: server-side preset expansion — meta + section order + seed blocks land under your content (your values win; seeds fill only empty fields). agent = the partner-agent card page. Unknown id: 400 BAD_PRESET. Response may carry warnings[{code:DUPLICATE_CONTENT, deckId}] when the account already has a content-identical site."
          }
        },
        "description": "Exactly one of content | brief is required. MULTI-PAGE (MP): content.meta.pages = [{slug,title,sections[],description?}] splits the site into up to 8 pages (slug \"\" = the index, exactly once; other slugs [a-z0-9-]{1,24}, unique, non-reserved). Each page renders its section subset with shared nav/footer; hero on the index only. Invalid maps: 400 BAD_PAGES. Absent = single-page (byte-identical to before)."
      },
      "Error": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean"
          },
          "code": {
            "type": "string"
          },
          "error": {
            "type": "string"
          }
        }
      }
    }
  },
  "security": [
    {
      "pat": []
    }
  ],
  "paths": {
    "/api/website/generate": {
      "post": {
        "summary": "Create a site (or regenerate in place)",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SiteCreate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "success — {deckId, previewUrl, bundleUrl}; replayed:true when a known external_ref short-circuited; templateLocked:true when your account's template lock rewrote meta.template"
          },
          "400": {
            "description": "BAD_EXTERNAL_REF | BAD_METADATA | missing company_name/logo"
          },
          "429": {
            "description": "SITE_QUOTA — plan site limit reached ({limit, current} in body)"
          }
        }
      }
    },
    "/api/website/list": {
      "get": {
        "summary": "List your sites",
        "parameters": [
          {
            "name": "external_ref",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Exact-match filter by your ref"
          }
        ],
        "responses": {
          "200": {
            "description": "{websites: [{deckId, name, external_ref, metadata, reviewStatus, round, shared?}]}"
          }
        }
      }
    },
    "/api/website/templates": {
      "get": {
        "summary": "Visual template catalog",
        "responses": {
          "200": {
            "description": "{templates:[{id,label,desc}], templateLock?} — partner templates appear only for accounts locked to them"
          }
        }
      }
    },
    "/api/website/{deckId}/content": {
      "get": {
        "summary": "Read the saved content model",
        "parameters": [
          {
            "name": "deckId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "assets",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            },
            "description": "Include saved assets (data URIs)"
          }
        ],
        "responses": {
          "200": {
            "description": "{content, hasAssets, external_ref, metadata, isOwner}"
          }
        }
      }
    },
    "/api/website/{deckId}/update": {
      "post": {
        "summary": "Save edited content and regenerate in place",
        "parameters": [
          {
            "name": "deckId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Regenerated; transitioned:'revising' when the edit put a customer approval on hold"
          }
        }
      }
    },
    "/api/website/{deckId}/domain": {
      "get": {
        "summary": "Read the reserved site address",
        "parameters": [
          {
            "name": "deckId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{subdomain, suggested}"
          }
        }
      },
      "put": {
        "summary": "Reserve the site address",
        "parameters": [
          {
            "name": "deckId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "subdomain": {
                    "type": "string",
                    "description": "Bare label (→ label.<target base domain>, created at deploy) or a full domain you control (verified at deploy). Reserved platform-wide."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "reserved"
          },
          "409": {
            "description": "SUBDOMAIN_TAKEN"
          }
        }
      }
    },
    "/api/website/{deckId}/submit-review": {
      "post": {
        "summary": "Mint/reuse the approval link (compliance loop)",
        "parameters": [
          {
            "name": "deckId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "approverEmail": {
                    "type": "string"
                  },
                  "password": {},
                  "expiresIn": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "{reviewUrl, round, …} — decisions arrive via website.approved / website.changes_requested webhooks; a site cannot publish unapproved"
          }
        }
      }
    },
    "/api/website/{deckId}/publish": {
      "post": {
        "summary": "Deploy to a VPS (dry-run plan by default)",
        "parameters": [
          {
            "name": "deckId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "targetId"
                ],
                "properties": {
                  "targetId": {
                    "type": "integer"
                  },
                  "dryRun": {
                    "type": "boolean",
                    "default": true
                  },
                  "releaseTs": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Plan (dry-run) or live result {ok, hostname, liveUrl, queued, log}. Publishes to one target serialize FIFO."
          },
          "409": {
            "description": "NOT_APPROVED — the approval gate"
          },
          "429": {
            "description": "PUBLISH_QUOTA — daily live-publish plan limit"
          }
        }
      }
    },
    "/api/website/{deckId}/unpublish": {
      "post": {
        "summary": "Stop serving a published site (churn teardown)",
        "description": "Removes the vhost (and the platform-created DNS record when a provider is configured); the site's releases stay on the server so re-publishing is instant. Emits website.unpublished.",
        "parameters": [
          {
            "name": "deckId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "targetId"
                ],
                "properties": {
                  "targetId": {
                    "type": "integer"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{ok, hostname, dnsRemoved}"
          }
        }
      }
    },
    "/api/website/{deckId}/rollback": {
      "post": {
        "summary": "Roll back to the previous release",
        "parameters": [
          {
            "name": "deckId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "rolled back"
          }
        }
      }
    },
    "/api/website/{deckId}/metrics": {
      "get": {
        "summary": "Aggregate visitor metrics for the published site",
        "parameters": [
          {
            "name": "deckId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 30
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{totalVisits, visitsByDay, topSections, topReferrers} — cookie-less, DNT-honoring, aggregate only"
          }
        }
      }
    },
    "/api/website/{deckId}/collaborators": {
      "get": {
        "summary": "Roster (owner + co-managers)",
        "parameters": [
          {
            "name": "deckId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{owner, collaborators, isOwner}"
          }
        }
      }
    },
    "/api/website/extract": {
      "post": {
        "summary": "AI-draft a content model from a brochure PDF, brochure photos, a text description, or a .txt/.md file",
        "description": "Four input forms, one response contract. (1) multipart field \"pdf\": one brochure PDF; (2) multipart field \"file\": up to 6 brochure photos (vision) — or ONE .txt/.md file (Markdown stripped to prose); (3) JSON body { text, lang? }: a pasted company description, 40-24,000 chars. Errors: 400 TEXT_TOO_SHORT / 413 TEXT_TOO_LONG / 400 BAD_LANG / 422 (unreadable file). All forms share the per-day AI-assist quota (429 ASSIST_QUOTA); quota is consumed only after validations pass. Response: { success, content|null, images[], meta:{ textChars, provider, model, source }, warnings[{code,text}] } — content is null with an AI_OFF warning when no AI key is configured. Text forms always return images:[] plus a NO_IMAGES warning (the logo is still required at generate).",
        "tags": [
          "websites"
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "text"
                ],
                "properties": {
                  "text": {
                    "type": "string",
                    "minLength": 40,
                    "maxLength": 24000,
                    "description": "The company description to draft from."
                  },
                  "lang": {
                    "type": "string",
                    "enum": [
                      "zh-Hant",
                      "zh-Hans",
                      "en",
                      "es"
                    ]
                  }
                }
              }
            },
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "properties": {
                  "pdf": {
                    "type": "string",
                    "format": "binary",
                    "description": "One brochure PDF (<=20 MB)."
                  },
                  "file": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "format": "binary"
                    },
                    "description": "Up to 6 brochure photos, or one .txt/.md file (<=1 MB)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Draft result — content may be null with warnings when AI is off or failed."
          }
        }
      }
    },
    "/api/website/base-domain": {
      "get": {
        "summary": "Current account base domain + verification records",
        "tags": [
          "websites"
        ],
        "responses": {
          "200": {
            "description": "{ domain, status, verification:{txt, wildcard} }"
          }
        }
      },
      "post": {
        "summary": "Claim your own base domain (PF-2)",
        "description": "Claim e.g. sites.yourbrand.com; add the returned TXT (ownership) + wildcard A (routing) DNS records, then POST /base-domain/verify. Once verified every site publishes as <slug>.<your domain>. 400 BAD_DOMAIN / 409 DOMAIN_TAKEN.",
        "tags": [
          "websites"
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain"
                ],
                "properties": {
                  "domain": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "pending + verification records"
          }
        }
      },
      "delete": {
        "summary": "Release the base domain",
        "tags": [
          "websites"
        ],
        "responses": {
          "200": {
            "description": "released"
          }
        }
      }
    },
    "/api/website/base-domain/verify": {
      "post": {
        "summary": "Verify DNS records for the claimed base domain",
        "tags": [
          "websites"
        ],
        "responses": {
          "200": {
            "description": "{ status: verified } or { status: pending, failures[] }"
          }
        }
      }
    },
    "/api/website/brand-tokens": {
      "get": {
        "summary": "Current brand tokens + font allowlist",
        "tags": [
          "websites"
        ],
        "responses": {
          "200": {
            "description": "{ tokens, fontPairs[] }"
          }
        }
      },
      "put": {
        "summary": "Set brand tokens overlaying the (locked) template (PF-3)",
        "description": "Token VALUES only: accent/ink/paper #RRGGBB (ink+paper together, >= 4.5:1 contrast), font_pair from the allowlist, radius 0-32px. Applies on each site next regenerate. 400 BAD_TOKENS.",
        "tags": [
          "websites"
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "accent": {
                    "type": "string"
                  },
                  "ink": {
                    "type": "string"
                  },
                  "paper": {
                    "type": "string"
                  },
                  "font_pair": {
                    "type": "string"
                  },
                  "radius": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "stored tokens"
          }
        }
      },
      "delete": {
        "summary": "Clear brand tokens",
        "tags": [
          "websites"
        ],
        "responses": {
          "200": {
            "description": "cleared"
          }
        }
      }
    }
  }
}